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 DialogPrimitive from "@radix-ui/react-dialog"
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"
>
<XIcon />
<span className="sr-only">Close</span>
<span className="sr-only">{i18n.t('ui.close', { ns: 'common' })}</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
+6 -5
View File
@@ -1,3 +1,4 @@
import i18n from '@/i18n'
import * as React from "react"
import {
ChevronLeftIcon,
@@ -71,13 +72,13 @@ function PaginationPrevious({
}: React.ComponentProps<typeof PaginationLink>) {
return (
<PaginationLink
aria-label="Go to previous page"
aria-label={i18n.t('ui.goPrevPage', { ns: 'common' })}
size="default"
className={cn("gap-1 px-2.5 sm:pl-2.5", className)}
{...props}
>
<ChevronLeftIcon />
<span className="hidden sm:block">Previous</span>
<span className="hidden sm:block">{i18n.t('ui.prevPage', { ns: 'common' })}</span>
</PaginationLink>
)
}
@@ -88,12 +89,12 @@ function PaginationNext({
}: React.ComponentProps<typeof PaginationLink>) {
return (
<PaginationLink
aria-label="Go to next page"
aria-label={i18n.t('ui.goNextPage', { ns: 'common' })}
size="default"
className={cn("gap-1 px-2.5 sm:pr-2.5", className)}
{...props}
>
<span className="hidden sm:block">Next</span>
<span className="hidden sm:block">{i18n.t('ui.nextPage', { ns: 'common' })}</span>
<ChevronRightIcon />
</PaginationLink>
)
@@ -111,7 +112,7 @@ function PaginationEllipsis({
{...props}
>
<MoreHorizontalIcon className="size-4" />
<span className="sr-only">More pages</span>
<span className="sr-only">{i18n.t('ui.morePages', { ns: 'common' })}</span>
</span>
)
}
+2 -1
View File
@@ -1,3 +1,4 @@
import i18n from '@/i18n'
import * as React from "react"
import * as SheetPrimitive from "@radix-ui/react-dialog"
import { XIcon } from "lucide-react"
@@ -72,7 +73,7 @@ function SheetContent({
{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">
<XIcon className="size-4" />
<span className="sr-only">Close</span>
<span className="sr-only">{i18n.t('ui.close', { ns: 'common' })}</span>
</SheetPrimitive.Close>
</SheetPrimitive.Content>
</SheetPortal>
+1 -1
View File
@@ -69,7 +69,7 @@ export default {
sourcePlaceholder: 'Select a data source',
query: 'LogQL query *',
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)',
noChannels: 'Create channels first under "Settings → Alert Notifications"',
channelDisabled: '(disabled)',
+10 -1
View File
@@ -64,4 +64,13 @@ export default {
untagged: '(untagged)',
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: {
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: {
deleteFailed: 'Failed to delete cluster',
@@ -52,13 +52,13 @@ export default {
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>.',
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',
rbacScriptComment2: '# 2) Extract the long-lived Token (it does not expire) and paste it into the Token field',
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:
'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)',
contextPlaceholder: 'Leave blank to use the default context',
submitAdd: 'Add Cluster',
+5 -5
View File
@@ -77,7 +77,7 @@ const collectors = {
logPath: 'Log Path',
logPathHostHint: 'Supports glob wildcards (e.g. /var/log/*.log /var/log/syslog); separate multiple paths with commas',
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)',
labelsHint: 'When valid JSON, attached as labels to every log line for filtering in Loki/Grafana; leave empty to skip',
submit: 'Create & Deploy',
@@ -103,11 +103,11 @@ const collectors = {
nsNoMatch: 'No matches',
nsLoadFailed: 'Failed to load namespaces',
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',
appSourcePlaceholder: 'Empty = default (controller-name fallback chain)',
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: {
default: 'Default: app.kubernetes.io/name → app → controller name → pod 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)',
appIncludePlaceholder: 'Empty = no limit; comma-separated, e.g. cex-spot-api, cex-asset-api',
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.',
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.',
'Keeps only logs whose app matches these values (<code>action: keep</code>) — the most direct way to cap log volume.',
logPathHint: 'Sidecar needs a shared log volume or the dedicated logs directory; click Deploy after saving.',
save: 'Save',
saving: 'Saving…',
validation: {
+8
View File
@@ -106,6 +106,14 @@ export default {
apply: 'Apply',
recent: 'Recently used',
},
ui: {
close: 'Close',
prevPage: 'Previous',
nextPage: 'Next',
goPrevPage: 'Go to previous page',
goNextPage: 'Go to next page',
morePages: 'More pages',
},
password: {
hide: 'Hide password',
show: 'Show password',
+3 -3
View File
@@ -10,7 +10,7 @@ export default {
deleteDockerDesc:
'Delete "{{name}}"? The Loki container on the host will be removed as well. This cannot be undone.',
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',
errDeploy: 'Deployment failed',
errStop: 'Failed to stop',
@@ -36,7 +36,7 @@ export default {
ready: 'Loki ready',
notReady: 'Loki not ready',
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',
deploy: 'Deploy',
deploying: 'Deploying...',
@@ -103,7 +103,7 @@ export default {
loading: 'Loading…',
templateNote: 'Generated by the deployment template',
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:
'Save only updates the deployment record; Save & Apply redeploys Loki and queries may be briefly interrupted.',
applyHintIdle: 'Takes effect on the next deployment.',
+1 -1
View File
@@ -24,7 +24,7 @@ export default {
},
delete: {
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: {
deleteFailed: 'Failed to delete host',
+28 -84
View File
@@ -8,7 +8,7 @@ export default {
query: 'Loki Query',
access: 'Query Access',
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.',
descSources: 'Configure Loki connections and authentication.',
},
@@ -20,7 +20,7 @@ export default {
sourceAria: 'Data source',
sourceEmptyOption: 'No data sources available',
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',
queryFailed: 'Query failed',
},
@@ -28,14 +28,28 @@ export default {
loadFailed: 'Failed to load data sources',
missingTitle: 'No Loki data source available',
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',
sectionAria: 'Data source management',
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}}',
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',
targetUrl: 'Direct URL',
targetClusterSvc: 'Cluster Service',
@@ -62,17 +76,6 @@ export default {
saveFailed: 'Failed to save',
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: {
aria: 'LogQL query',
valuesOf: 'Values of {{label}}',
@@ -85,7 +88,7 @@ export default {
templates: {
aria: 'Quick query templates',
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',
app: 'App',
appWithKey: 'App ({{key}})',
@@ -95,12 +98,10 @@ export default {
pickNsFirst: 'Pick a namespace first to narrow down',
noPod: 'No pods under this app',
optional: 'Optional',
scopeLogs: 'Query this scope',
errorLogs: 'Errors (error)',
warnLogs: 'Warnings (warn)',
podLogs: 'Specific pod 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: {
browser: 'Label Browser',
@@ -115,11 +116,11 @@ export default {
loading: 'Loading…',
noValues: 'No matching values',
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: {
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…',
histogramEmpty: 'No log volume data',
timelineAria: 'Log volume timeline',
@@ -136,16 +137,10 @@ export default {
summary_one: 'Page {{page}} · {{count}} entry',
summaryMore: 'Page {{page}} · {{count}} entries · 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',
loadingQuery: 'Querying Loki',
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',
domainFilter: 'Domain filter',
domainPlaceholder: 'Type or pick a domain',
@@ -165,42 +160,6 @@ export default {
idleHint: 'You can also build queries quickly in the Label browser',
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: {
modesAria: 'Ingress statistics mode',
modeTraffic: 'Endpoints PV / UV',
@@ -208,7 +167,7 @@ export default {
modeSlow: 'Top 10 slow requests',
modeDetail: 'Request details',
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',
statUv: 'UV (IP)',
statErrors: '4xx / 5xx',
@@ -219,9 +178,9 @@ export default {
error4xx: '4xx only',
slowThreshold: 'Slow request threshold',
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.',
trafficHint: 'Success rate = 2xx / 3xx requests ÷ PV; query strings are stripped from paths.',
trafficHint: 'Success rate = 2xx / 3xx ÷ PV',
thHost: 'Request Host',
thPath: 'Path',
thSuccess: 'Success rate',
@@ -257,7 +216,7 @@ export default {
loadFailed: 'Failed to load context',
},
access: {
labelsUnlimited: 'Unlimited · all namespaces',
labelsUnlimited: 'Unrestricted · all namespaces',
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',
saveFailed: 'Failed to save policy group',
@@ -309,19 +268,4 @@ export default {
loading: 'Loading…',
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)',
create: 'New channel',
// 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',
emptyAfter: ' page',
emptyAfter: 'then link rules to it on the alerts page.',
webhookConfigured: 'Webhook configured',
webhookMissing: 'Not configured',
secretConfigured: ' · Signing secret configured',
@@ -82,7 +82,7 @@ export default {
headerHint: 'Enables LDAP sign-in and bulk user import',
errLoad: 'Failed to load LDAP configuration',
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 *',
portLabel: 'Port',
ldapsLabel: 'LDAPS',
@@ -133,7 +133,7 @@ export default {
timezone: 'Timezone',
timezoneAria: 'Theme schedule timezone',
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: {
title: 'Appearance & Language',
+1 -1
View File
@@ -10,7 +10,7 @@ export default {
deleteWarning_one:
'Delete "{{name}}"? Warning: {{count}} deployment still uses this storage; its object storage config will stop working.',
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',
errSetDefault: 'Failed to set the default storage',
},
+1 -1
View File
@@ -80,7 +80,7 @@ export default {
clear: 'Clear selection',
selectAll: 'Select all',
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}}',
failed: 'Bulk delete failed',
},
+2 -2
View File
@@ -32,7 +32,7 @@ const wizard = {
ready: 'Ready',
connected: 'Connected: {{names}}',
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',
hostHint: 'Connect a host via SSH key or password (used for deployment and later collector installation)',
addHost: 'Add Host',
@@ -44,7 +44,7 @@ const wizard = {
manage: 'Manage Storage',
add: 'Add 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: {
meta: '{{mode}} · Namespace {{namespace}} · {{endpoint}}',
+2 -2
View File
@@ -34,7 +34,7 @@ export default {
sent: '已发送',
cooldown: '冷却跳过',
failed: '失败',
hits_other: '{{count}} 条命中',
hits: '{{count}} 条命中',
},
tpl: {
nsError: {
@@ -65,7 +65,7 @@ export default {
sourcePlaceholder: '选择数据源',
query: 'LogQL 查询 *',
queryHint:
'常用:{app="xxx"} |= "FATAL" / {namespace=~".+"} |~ "(?i)error" / {app="x"} |= "OutOfMemoryError"',
'常用:{app="xxx"} |= "FATAL"',
channels: '通知通道(可多选,扇出到多个告警群)',
noChannels: '请先在「系统设置 → 告警通知」创建通道',
channelDisabled: '(禁用)',
+10 -7
View File
@@ -1,7 +1,6 @@
// 日志分析页(admin)— 经 i18n/domains.ts 注册,页面入口 import '@/i18n/domains'
export default {
title: '日志分析',
subtitle: '按应用与命名空间统计日志量、错误量与趋势,定位日志大户',
adminOnly: {
title: '仅管理员可访问',
desc: '日志分析面向管理员提供全量日志统计,请联系管理员开通。',
@@ -24,7 +23,6 @@ export default {
},
trend: {
title: '日志量趋势',
subtitle: '总量 / 错误 / 告警 按时间分桶',
seriesTotal: '总量',
seriesError: '错误',
seriesWarn: '告警',
@@ -32,25 +30,21 @@ export default {
},
stream: {
title: '应用日志量流图',
subtitle: '日志量 Top {{count}} 应用随时间的堆叠分布',
empty: '没有应用维度的数据',
},
pie: {
title: '命名空间分布',
subtitle: '按命名空间统计的日志量占比',
empty: '没有命名空间维度的数据',
other: '其他',
},
daily: {
fixedBadge: '固定近 7 天',
title: '应用每日日志量',
subtitle: '固定统计近 7 个自然日的 Top 应用分布,不受上方时间范围影响',
empty: '近 7 天没有应用维度的数据',
},
table: {
title: '应用日志量排行',
subtitle: '按日志量排序的 Top 10 应用,点击「查看日志」跳转查询页',
rank: '#',
app: '应用',
appWithKey: '应用({{key}})',
@@ -64,4 +58,13 @@ export default {
untagged: '(未打标签)',
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:
'只授予 <code>log</code> 命名空间权限,Token 泄露也不会影响其他命名空间;<strong>不要使用 cluster-admin</strong>。',
rbacScriptIntro:
'一次性创建 <code>log</code> 命名空间、ServiceAccount、命名空间级 Role 和长期 Token(在集群里执行一次,Token 粘贴到下方输入框):',
'在集群执行一次,创建命名空间 / SA / Role / 长期 Token:',
rbacScriptComment1: '# 1) 一次创建:命名空间 + SA + 命名空间级 Role + 长期 Token',
rbacScriptComment2: '# 2) 取出长期 Token(不自动过期),粘贴到 Token 输入框',
rbacNamespaceNote:
'上面的 Namespace 由管理员创建,平台 Token 本身没有任何集群级权限。只有确实需要平台自建命名空间时,才额外授予 <code>namespaces: create/get</code> 的 ClusterRole;注意 namespace 的 create 无法按名称限制,所以预建 <code>log</code> 更安全。',
'Token 无集群级权限;仅当需要平台自建命名空间时才额外授予 namespaces: create/get。',
rbacTokenNote:
'Token 长期有效,但只绑定 <code>log</code> 命名空间的 Role(仅能操作该命名空间的 Pod / 工作负载),泄露影响面仅限于 <code>log</code>;服务端会转成 kubeconfig 并用 <code>ENCRYPTION_KEY</code> 加密存储,数据库里只有 <code>ENC:</code> 密文,接口不返回明文。轮换时删除该 Secret 重建即可,旧 Token 立即失效。',
'Token 仅限 log 命名空间,泄露影响面小;服务端加密存储。轮换:删除该 Secret 重建。',
contextLabel: '上下文(可选)',
contextPlaceholder: '留空则使用默认上下文',
submitAdd: '添加集群',
+5 -5
View File
@@ -80,7 +80,7 @@ const collectors = {
logPath: '采集日志路径',
logPathHostHint: '支持 glob 通配(如 /var/log/*.log /var/log/syslog);多个路径用逗号分隔',
logPathSidecarHint:
'填写业务写入的文件路径,如 /var/log/app/*.log。已有日志卷会自动共享;无日志卷时仅在专用 logs 目录创建共享卷。/tmp/test.log 等普通目录不会自动挂载。',
'填业务写入的路径,如 /var/log/app/*.log;仅共享已有日志卷或专用 logs 目录。',
labels: '自定义标签(JSON,可选)',
labelsHint: '合法 JSON 时将附加到每条日志的标签,便于在 Loki/Grafana 中筛选;留空则不加',
submit: '创建并部署',
@@ -107,11 +107,11 @@ const collectors = {
nsLoadFailed: '加载命名空间失败',
// nsHint/appSourceHint/appIncludeHint 含行内 <code>,渲染处用 Trans 组件
nsHint:
'勾「所有命名空间」= 采集全集群;否则只采集勾选的命名空间。生成 Promtail 的 <code>kubernetes_sd_configs.namespaces</code> 白名单。',
'勾「所有命名空间」= 采集全集群;否则只采勾选项。',
appLabelSource: 'app 标签取值来源',
appSourcePlaceholder: '留空 = 默认(controller 名回退链)',
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: {
default: '默认:app.kubernetes.io/name → app → controller 名 → pod 名',
controllerName: 'controller_name(Pod 的 ReplicaSet 名,去 hash = Deployment 名)',
@@ -122,8 +122,8 @@ const collectors = {
appInclude: '只采集这些 app(可选)',
appIncludePlaceholder: '留空 = 不限;多个用逗号分隔,如 cex-spot-api, cex-asset-api',
appIncludeHint:
'填了之后 Promtail 只保留 app 命中这些值的日志(<code>action: keep</code>),是控制日志量最直接的手段。值需与上面「app 标签取值来源」解析出的 app 一致。',
logPathHint: 'Sidecar 需使用业务容器的共享日志卷,或专用 logs 目录。保存后点击「部署」应用新配置。',
'只保留 app 命中这些值的日志(<code>action: keep</code>),是控制日志量最直接的手段。',
logPathHint: 'Sidecar 需共享日志卷或专用 logs 目录;保存后点「部署」生效。',
save: '保存',
saving: '保存中…',
validation: {
+8
View File
@@ -105,6 +105,14 @@ export default {
apply: '应用时间',
recent: '最近使用',
},
ui: {
close: '关闭',
prevPage: '上一页',
nextPage: '下一页',
goPrevPage: '前往上一页',
goNextPage: '前往下一页',
morePages: '更多页码',
},
password: {
hide: '隐藏密码',
show: '显示密码',
+3 -3
View File
@@ -9,7 +9,7 @@ export default {
// 删除确认:host-docker 连容器一起删,其余模式仅删记录不卸载资源
deleteDockerDesc: '确定要删除「{{name}}」吗?将同时移除主机上的 Loki 容器,此操作不可恢复。',
deleteRecordDesc:
'确定要删除「{{name}}」吗?删除记录不会自动卸载集群/主机上已部署的 Loki 资源,此操作不可恢复。',
'删除「{{name}}」不会卸载已部署的 Loki 资源,此操作不可恢复。',
errLoadRefs: '加载集群/主机/存储数据失败',
errDeploy: '部署失败',
errStop: '停止失败',
@@ -35,7 +35,7 @@ export default {
ready: 'Loki 就绪',
notReady: 'Loki 未就绪',
runningHint:
'提示:到「日志查询 → 数据源」关联 Loki 后即可查询;跨集群/公网访问需先为 Loki 配置 Ingress 域名。',
'到「日志查询 → 数据源」关联 Loki 后即可查询。',
configure: '配置',
deploy: '部署',
deploying: '部署中...',
@@ -101,7 +101,7 @@ export default {
loading: '读取中…',
templateNote: '由部署模板生成',
yamlLabel: '保留配置 YAML',
yamlHint: '编辑 limits_config.retention_period,例如 90d 或 2160h。此处只接受这一项配置。',
yamlHint: '编辑 retention_period,如 90d / 2160h;仅支持这一项。',
applyHintRunning: '仅保存会更新部署记录;保存并应用会重新部署 Loki,查询可能短暂中断。',
applyHintIdle: '保存后在下次部署时生效。',
cancel: '取消',
+1 -1
View File
@@ -24,7 +24,7 @@ export default {
},
delete: {
warning:
'确定要删除「{{name}}」吗?警告:仍有 {{dep}} 个部署、{{col}} 个采集策略引用该主机,删除后它们将无法工作。',
'确定删除「{{name}}」吗?仍有 {{dep}} 个部署、{{col}} 个采集策略在引用。',
},
toast: {
deleteFailed: '删除主机失败',
+27 -81
View File
@@ -7,7 +7,7 @@ export default {
query: 'Loki 查询',
access: '查询授权',
sources: '数据源管理',
descQuery: '按日志流和时间范围检索;拖动时间线缩小范围,点日志查看前后文。',
descQuery: '按日志流与时间范围检索,可拖时间线、看前后文。',
descAccess: '用策略组复用日志范围,并一次关联多个用户。',
descSources: '配置 Loki 连接与认证方式。',
},
@@ -19,7 +19,7 @@ export default {
sourceAria: '数据源',
sourceEmptyOption: '暂无可用数据源',
limitLabel: '每页条数',
limitHint: '每页最多 1000 条;翻页只保留当前页。大范围先看时间线,再拖选高峰时段。',
limitHint: '每页最多 1000 条,翻页仅保留当前页。',
rangeInvalid: '请选择有效的起止时间',
queryFailed: '查询失败',
},
@@ -27,14 +27,28 @@ export default {
loadFailed: '加载数据源失败',
missingTitle: '还没有可用的 Loki 数据源',
missingBody:
'请先到「数据源管理」关联 Loki:集群内可直接选「集群服务」(命名空间 / Service / 端口,无需 Ingress);跨集群或公网查询,请先给 Loki 配置 Ingress 域名,再用「URL 直连」填写该域名。未关联数据源无法查询。',
'请先到「数据源管理」关联 Loki:集群内选「集群服务」,跨集群 / 公网用「URL 直连」。',
goConfigure: '去配置数据源',
sectionAria: '数据源管理',
heading: 'Loki 数据源',
hint: '提示:Loki 部署在集群内时,可直接添加「集群服务」数据源(无需 Ingress);跨集群 / 公网查询需先为 Loki 配置 Ingress 域名,再用「URL 直连」关联。未关联数据源时,查询页无法使用。',
hint: '集群内 Loki 选「集群服务」即可;跨集群 / 公网需先配 Ingress 域名,再用「URL 直连」。',
managedCluster: '集群 {{name}}',
managedBadge: '平台部署 · 随部署管理',
add: '+ 手动添加',
countLabel: '共 {{count}} 个',
create: '新建数据源',
createTitle: '新建数据源',
editTitle: '编辑数据源',
edit: '编辑',
cancel: '取消',
testing: '测试中…',
deleteTitle: '删除数据源',
deleteDesc: '删除「{{name}}」后,基于它的查询与授权策略将失效,确定删除吗?',
deleteAria: '删除 {{name}}',
defaultBadge: '默认',
emptyTitle: '还没有数据源',
emptyDesc: '新建「集群服务」或「URL 直连」数据源后,即可在查询页检索日志。',
typeLabel: '类型',
namePlaceholder: 'prod-loki',
nameLabel: '名称',
targetUrl: 'URL 直连',
targetClusterSvc: '集群服务',
@@ -61,16 +75,6 @@ export default {
saveFailed: '保存失败',
remove: '删除',
},
range: {
lastMinutes: '最近 {{count}} 分钟',
lastHours: '最近 {{count}} 小时',
custom: '自定义时间',
title: '选择时间范围',
start: '开始时间',
end: '结束时间',
apply: '应用时间',
recent: '最近使用',
},
editor: {
aria: 'LogQL 查询语句',
valuesOf: '{{label}} 的值',
@@ -83,7 +87,7 @@ export default {
templates: {
aria: '快捷查询模板',
title: '快捷生成 LogQL',
hint: '先选 namespace,再选 app,最后选副本 pod;app 候选只来自所选命名空间。',
hint: '先选 namespace 缩小范围;选中 app / pod 自动查询。',
namespace: '命名空间',
app: 'App',
appWithKey: 'App({{key}})',
@@ -93,12 +97,10 @@ export default {
pickNsFirst: '先选命名空间可缩小范围',
noPod: '该 app 下无副本',
optional: '可选',
scopeLogs: '查询该范围日志',
errorLogs: '错误 error',
warnLogs: '告警 warn',
podLogs: '指定副本日志',
ingressLogs: 'Ingress 访问日志',
scopeHint: '先选 namespace,再选 app 或 pod,也可在 Label 浏览器选值',
scopeHint: '选 app / pod 即自动查询;也可在 Label 浏览器选值',
},
labels: {
browser: 'Label 浏览器',
@@ -113,11 +115,11 @@ export default {
loading: '加载中…',
noValues: '没有匹配值',
useAsValue: '使用“{{value}}”作为值',
hint: '先选 namespace 可查该命名空间全部副本;再选 app / service_name 可缩小到某个服务(无 app 标签的工作负载用 namespace 即可)。候选来自当前时间范围内已写入 Loki 的日志;授权范围仍由服务端限制。',
hint: '先选 namespace,再选 app / service_name 逐步缩小;候选为当前时间范围内的日志。',
},
volume: {
statsLoading: '正在统计整个时间范围的日志量,日志页可继续浏览。',
statsError: '日志量统计超时或失败;日志页仍可使用。可缩小时间范围后重新查询。',
statsError: '日志量统计失败;可缩小时间范围后重试。',
histogramLoading: '统计中…',
histogramEmpty: '暂无日志量数据',
timelineAria: '日志量时间线',
@@ -131,16 +133,10 @@ export default {
title: '日志结果',
summary: '第 {{page}} 页 · 本页 {{count}} 条',
summaryMore: '第 {{page}} 页 · 本页 {{count}} 条 · 还有更多',
viewAria: '日志展示方式',
viewRaw: '原始日志',
viewTable: '表格',
viewLine: '折线图',
viewStream: '流图',
viewPie: '饼图',
loadingPage: '正在加载日志页',
loadingQuery: '正在查询 Loki',
loadingWait: '已等待 {{seconds}} 秒。大范围查询可能需要较长时间。',
pagingHint: '日志按时间从新到旧翻页;图表展示当前页,顶部时间线展示整个查询范围。',
pagingHint: '日志按时间从新到旧翻页;顶部时间线为整个查询范围。',
domainAll: '全部域名',
domainFilter: '域名筛选',
domainPlaceholder: '输入或选择域名',
@@ -160,42 +156,6 @@ export default {
idleHint: '也可以使用 Label 浏览器快速构建查询',
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: {
modesAria: 'Ingress 统计方式',
modeTraffic: '接口 PV / UV',
@@ -203,7 +163,7 @@ export default {
modeSlow: '慢请求 Top 10',
modeDetail: '请求明细',
summary:
'仅统计当前页已加载的 {{loaded}} 条日志,其中 {{parsed}} 条为 ingress-nginx 访问日志。翻页后重新统计;UV 按非空客户端 IP 去重。',
'基于已加载的 {{loaded}} 条日志统计',
statPv: 'PV',
statUv: 'UV(IP)',
statErrors: '4xx / 5xx',
@@ -214,9 +174,9 @@ export default {
error4xx: '只看 4xx',
slowThreshold: '慢请求阈值',
seconds: '{{count}} 秒',
slowHint: '依据 access log 的 request_time 排序;408 / 499 / 504 仅作超时线索,不能单凭状态码断定后端超时。',
slowHint: '按 request_time 排序;408 / 499 / 504 为超时线索。',
slowMissing: '{{count}} 条缺少有效耗时。',
trafficHint: '访问成功率按 2xx / 3xx 请求数 ÷ PV 计算;路径已去掉查询参数。',
trafficHint: '成功率 = 2xx / 3xx ÷ PV',
thHost: '请求 Host',
thPath: '接口路径',
thSuccess: '成功率',
@@ -301,18 +261,4 @@ export default {
loading: '加载中…',
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)',
create: '新建通道',
// 空态文案按「前段 + 链接 + 后段」拼接(链接指向日志告警页)
emptyBefore: '尚无通知通道 —— 每个告警群创建一个通道(如「应用告警群」「Nginx 告警群」),在',
emptyBefore: '尚无通知通道 —— 每个告警群建一个通道,',
emptyLink: '日志告警',
emptyAfter: '页把规则关联到对应通道',
emptyAfter: '在告警规则页把规则关联到对应通道。',
webhookConfigured: 'Webhook 已配置',
webhookMissing: '未配置',
secretConfigured: ' · 签名已配置',
@@ -82,7 +82,7 @@ export default {
headerHint: '启用后支持 LDAP 登录与批量导入',
errLoad: '加载 LDAP 配置失败',
disabledHint:
'LDAP 未启用 —— 打开开关填写目录服务信息(服务器 / Bind DN / Base DN),保存后用户管理页将出现「从 LDAP 导入」',
'LDAP 未启用 —— 开启并填写目录服务信息后,用户管理页将出现「从 LDAP 导入」。',
hostLabel: '服务器地址 *',
portLabel: '端口',
ldapsLabel: 'LDAPS',
@@ -133,7 +133,7 @@ export default {
timezone: '判定时区',
timezoneAria: '主题定时切换时区',
browser: '跟随浏览器(本地)',
autoHint: '按所选时区,{{start}}:00–{{end}}:00 使用浅色,其余时段深色;顶栏太阳/月亮可随时手动覆盖。',
autoHint: '按所选时区 {{start}}:00–{{end}}:00 浅色,其余深色;顶栏可随时手动切换。',
},
prefsCard: {
title: '外观与语言',
+1 -1
View File
@@ -8,7 +8,7 @@ export default {
emptyDescription: '添加第一个 Loki 存储后端',
// 删除确认文案(有部署引用时的警告;count 走中文无复数)
deleteWarning:
'确定要删除「{{name}}」吗?警告:仍有 {{count}} 个部署使用该存储,删除后这些部署的对象存储配置将失效。',
'确定删除「{{name}}」吗?仍有 {{count}} 个部署在使用,删除后其对象存储将失效。',
errDelete: '删除存储配置失败',
errSetDefault: '设置默认存储失败',
},
+2 -2
View File
@@ -67,7 +67,7 @@ export default {
noEmail: '无邮箱',
empty: '目录下没有用户',
importing: '导入中...',
importCount_other: '导入 {{count}} 个用户',
importCount: '导入 {{count}} 个用户',
importFailed: '导入失败',
loadFailed: '加载 LDAP 用户失败',
},
@@ -78,7 +78,7 @@ export default {
clear: '清除选择',
selectAll: '全选',
confirmTitle: '确认批量删除',
confirmDesc: '确定要删除选中的 {{count}} 个用户吗?受保护的账号(自己/最后一个管理员)将被跳过,此操作不可恢复。',
confirmDesc: '删除选中的 {{count}} 个用户?受保护账号会跳过,操作不可恢复。',
skippedWarn: '{{count}} 项被跳过:{{reasons}}',
failed: '批量删除失败',
},
+2 -2
View File
@@ -33,7 +33,7 @@ const wizard = {
ready: '已就绪',
connected: '已接入:{{names}}',
nameSep: '、',
k8sHint: '通过 kubeconfig 或 ServiceAccount Token 接入集群,创建后自动验证连通性',
k8sHint: 'kubeconfig 或 Token 接入,创建后自动验证连通性。',
addCluster: '添加集群',
hostHint: 'SSH 密钥或密码接入主机(用于部署与后续采集器安装)',
addHost: '添加主机',
@@ -45,7 +45,7 @@ const wizard = {
manage: '管理存储',
add: '添加存储',
configure: '配置存储',
hint: '推荐对象存储(MinIO / RustFS / S3 / OSS):多副本与数据保留更可靠。添加时可用「测试连接」验证凭证,并自动创建不存在的桶;跳过则使用本地磁盘。',
hint: '推荐对象存储(MinIO / RustFS / S3 / OSS),更可靠;跳过则使用本地磁盘。',
},
deploy: {
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 { BellRing, Plus, Play, Pencil, Trash2 } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import { Input } from '@/components/ui/input'
import { Switch } from '@/components/ui/switch'
import { Card, CardContent } from '@/components/ui/card'
@@ -380,14 +381,12 @@ const AlertsPage = memo(function AlertsPage() {
const checked = field.value.includes(String(ch.id))
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'}`}>
<input
type="checkbox"
<Checkbox
checked={checked}
onChange={(e) => {
if (e.target.checked) field.onChange([...field.value, String(ch.id)])
onCheckedChange={(v) => {
if (v) field.onChange([...field.value, String(ch.id)])
else field.onChange(field.value.filter((x) => x !== String(ch.id)))
}}
className="accent-primary"
/>
{ch.name}
{!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 { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import { Checkbox } from '@/components/ui/checkbox'
import type { Collector } from '@/types'
// 表单 schema:错误消息经 t 注入(随语言重建,RHF 每次渲染刷新 resolver)
@@ -153,7 +154,7 @@ export const CollectorEditDialog = memo(function CollectorEditDialog({ collector
{nsError && <p className="text-xs text-destructive">{nsError}</p>}
<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">
<input type="checkbox" checked={allSelected} onChange={setAll} />
<Checkbox checked={allSelected} onCheckedChange={() => setAll()} />
{t('edit.allCheckbox')}
</label>
<div className="p-2">
@@ -60,7 +60,7 @@ export const RecentDeploymentsCard = memo(function RecentDeploymentsCard({
<p className="text-sm font-medium text-foreground truncate">
{d.name}
</p>
<p className="text-xs text-muted-foreground">
<p className="truncate text-xs text-muted-foreground">
{d.mode} &middot; {d.namespace || 'loki'}
</p>
</div>
@@ -79,7 +79,7 @@ export const ResourceListCard = memo(function ResourceListCard({
)}
</div>
<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)
)}`}
>
+2 -2
View File
@@ -46,7 +46,7 @@ const DashboardPage = memo(function DashboardPage() {
label={t('stat.totalDeployments')}
value={stats.totalDeployments}
icon={Rocket}
iconColor="text-primary"
iconColor="text-success"
iconBg="bg-success/10"
sub={{
icon: TrendingUp,
@@ -100,7 +100,7 @@ const DashboardPage = memo(function DashboardPage() {
{/* 快速开始引导:尚无任何部署时置顶(状态推导式引导) */}
{(data?.stats.totalDeployments ?? 0) === 0 && (
<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="w-10 h-10 rounded-xl bg-primary/10 flex items-center justify-center">
<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 { useTranslation } from 'react-i18next'
import { Input } from '@/components/ui/input'
import { Checkbox } from '@/components/ui/checkbox'
import { logsApi } from '@/api/logs'
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) }}>
{open ? <ChevronDown className="size-4" /> : <ChevronRight className="size-4" />}
<Tags className="size-4 text-info" />{t('labels.browser')}
@@ -88,7 +89,7 @@ export function LokiLabelBrowser({ sourceId, query, onQueryChange, start, end }:
</div>
<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>
<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>
{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>
@@ -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 })
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">
<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>
@@ -96,7 +96,7 @@ export function PolicyGroupsPage({ sources }: { sources: LogSource[] }) {
{error && <ErrorState message={error} onRetry={() => void reload()} />}
{!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'}`}>
<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">
{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>
@@ -105,7 +105,7 @@ export function PolicyGroupsPage({ sources }: { sources: LogSource[] }) {
)}
</button>)}</div>
</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="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