diff --git a/src/App.tsx b/src/App.tsx index 862b31551..638c48a04 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -47,8 +47,6 @@ import './global.variable.less'; // 顶层路由组件懒加载:SharedDetail 会连带引入事件详情 + 全部数据源插件注册表(近千个模块), // 只在 /share/alert-his-events 路由用到,改为懒加载后登录页等页面不再 eager 拉起这些依赖。 -const TaskOutput = React.lazy(() => import('@/pages/taskOutput')); -const TaskHostOutput = React.lazy(() => import('@/pages/taskOutput/host')); const SharedDetail = React.lazy(() => import('@/pages/event/DetailNG/SharedDetail')); interface IProfile { @@ -349,8 +347,6 @@ function App() { > }> - - <> {location.pathname !== `${basePrefix}/out-of-service` && } diff --git a/src/components/SideMenu/locale/en_US.ts b/src/components/SideMenu/locale/en_US.ts index 701ae0a1f..0d2d03bc9 100644 --- a/src/components/SideMenu/locale/en_US.ts +++ b/src/components/SideMenu/locale/en_US.ts @@ -36,7 +36,7 @@ const en_US = { alert_subscribes: 'Subscription rules', job: 'Self-healing', job_tpls: 'Self-Healing script', - job_tasks: 'Task history', + job_tasks: 'Execution history', events: 'Events', cur_events: 'Active events', his_events: 'Historical events', diff --git a/src/components/SideMenu/locale/ja_JP.ts b/src/components/SideMenu/locale/ja_JP.ts index a9104501c..d761e656c 100644 --- a/src/components/SideMenu/locale/ja_JP.ts +++ b/src/components/SideMenu/locale/ja_JP.ts @@ -36,7 +36,7 @@ const ja_JP = { alert_subscribes: 'サブスクリプションルール', job: 'アラート自動修復', job_tpls: '自動修復スクリプト', - job_tasks: '履歴タスク', + job_tasks: '実行履歴', events: 'アラートイベント', cur_events: 'アクティブアラート', his_events: '履歴アラート', diff --git a/src/components/SideMenu/locale/ru_RU.ts b/src/components/SideMenu/locale/ru_RU.ts index 4fd9efee2..098f4eddb 100644 --- a/src/components/SideMenu/locale/ru_RU.ts +++ b/src/components/SideMenu/locale/ru_RU.ts @@ -36,7 +36,7 @@ const ru_RU = { alert_subscribes: 'Подписки', job: 'Самовосстановление', job_tpls: 'Скрипт самовосстановления', - job_tasks: 'Задача самовосстановления', + job_tasks: 'История выполнения', events: 'Оповещения', cur_events: 'Действующие оповещения', his_events: 'История оповещений', diff --git a/src/components/SideMenu/locale/zh_CN.ts b/src/components/SideMenu/locale/zh_CN.ts index ef1b40a2e..8271a0d2c 100644 --- a/src/components/SideMenu/locale/zh_CN.ts +++ b/src/components/SideMenu/locale/zh_CN.ts @@ -36,7 +36,7 @@ const zh_CN = { alert_subscribes: '订阅规则', job: '告警自愈', job_tpls: '自愈脚本', - job_tasks: '历史任务', + job_tasks: '执行历史', events: '告警事件', cur_events: '活跃告警', his_events: '历史告警', diff --git a/src/components/SideMenu/locale/zh_HK.ts b/src/components/SideMenu/locale/zh_HK.ts index 14a603925..e2f463049 100644 --- a/src/components/SideMenu/locale/zh_HK.ts +++ b/src/components/SideMenu/locale/zh_HK.ts @@ -36,7 +36,7 @@ const zh_HK = { alert_subscribes: '訂閱規則', job: '告警自癒', job_tpls: '自癒腳本', - job_tasks: '歷史任務', + job_tasks: '執行歷史', events: '告警事件', cur_events: '活躍告警', his_events: '歷史告警', diff --git a/src/locales/common/locale/en_US.ts b/src/locales/common/locale/en_US.ts index b272ceb90..82bc44459 100644 --- a/src/locales/common/locale/en_US.ts +++ b/src/locales/common/locale/en_US.ts @@ -255,6 +255,20 @@ const en_US = { 'If your role is an administrator, you can execute the script on any machine; otherwise, you can only execute the script on the machines under the business group with administrative rights', 'task.allOptionLabel': 'All tasks', 'task.error.no_group': 'Business group does not exist, cannot create task. Please contact the administrator', + 'task.host.label': 'Target hosts', + 'task.confirm.execute.title': 'Execute now?', + 'task.confirm.execute.hosts': 'The script will run on {{count}} host(s)', + 'task.confirm.execute.account': 'Run as: {{account}}', + 'task.confirm.execute.script': 'First line: {{script}}', + 'task.action.start': 'Start', + 'task.action.pause': 'Pause', + 'task.action.cancel': 'Cancel', + 'task.action.kill': 'Kill', + 'task.action.ignore': 'Ignore', + 'task.action.redo': 'Redo', + 'task.confirm.cancel': 'Cancel this task? Hosts not yet started will be skipped', + 'task.confirm.kill': 'Kill? Running processes will be forcibly terminated', + 'task.confirm.host.kill': 'Kill the execution on this host?', 'last.1.days': 'Last 1 day', 'last.7.days': 'Last 7 days', diff --git a/src/locales/common/locale/ja_JP.ts b/src/locales/common/locale/ja_JP.ts index 4f999ffb1..ac1107910 100644 --- a/src/locales/common/locale/ja_JP.ts +++ b/src/locales/common/locale/ja_JP.ts @@ -224,7 +224,7 @@ const ja_JP = { 'tpl.callback': '警報自己修復コールバックアドレス', 'tpl.allOptionLabel': '全スクリプト', - task: '自己修復タスク', + task: '実行履歴', 'task.create': 'タスクを作成', 'task.title': 'タイトル', 'task.done': '完了したか', @@ -254,6 +254,20 @@ const ja_JP = { 'task.tip.content': 'もしロールが管理者なら、どのマシンでもスクリプトを実行できます。それ以外は、管理権限のあるビジネスグループのマシンでのみスクリプトを実行できます', 'task.allOptionLabel': '全スクリプト', 'task.error.no_group': 'ビジネスグループが存在しないためタスクを作成できません。管理者にお問い合わせください', + 'task.host.label': '実行マシン', + 'task.confirm.execute.title': '今すぐ実行しますか?', + 'task.confirm.execute.hosts': '{{count}} 台のホストでスクリプトを実行します', + 'task.confirm.execute.account': '実行アカウント:{{account}}', + 'task.confirm.execute.script': 'スクリプト先頭行:{{script}}', + 'task.action.start': '開始', + 'task.action.pause': '一時停止', + 'task.action.cancel': '実行をキャンセル', + 'task.action.kill': '強制終了', + 'task.action.ignore': '無視', + 'task.action.redo': '再実行', + 'task.confirm.cancel': 'このタスクをキャンセルしますか?未開始のマシンは実行されません', + 'task.confirm.kill': '強制終了しますか?実行中のプロセスが kill されます', + 'task.confirm.host.kill': 'このホストの実行を強制終了しますか?', 'last.1.days': '最近 1 日', 'last.7.days': '最近 7 日', diff --git a/src/locales/common/locale/ru_RU.ts b/src/locales/common/locale/ru_RU.ts index 60dfa2ac0..91cc7b24a 100644 --- a/src/locales/common/locale/ru_RU.ts +++ b/src/locales/common/locale/ru_RU.ts @@ -223,7 +223,7 @@ const ru_RU = { 'tpl.create.task': 'Создать задачу', 'tpl.callback': 'Адрес обратного вызова для самовосстановления предупреждения', 'tpl.allOptionLabel': 'Все скрипты', - task: 'Задача самовосстановления', + task: 'История выполнения', 'task.create': 'Создать задачу', 'task.title': 'Заголовок', 'task.done': 'Завершено ли', @@ -254,6 +254,20 @@ const ru_RU = { 'Если ваша роль - администратор, вы можете выполнять скрипты на любом хосте; в противном случае вы можете выполнять скрипты только для бизнес-группы, к которой относится хост', 'task.allOptionLabel': 'Все задачи', 'task.error.no_group': 'Бизнес-группа не существует, невозможно создать задачу. Обратитесь к администратору', + 'task.host.label': 'Целевые хосты', + 'task.confirm.execute.title': 'Выполнить сейчас?', + 'task.confirm.execute.hosts': 'Скрипт будет запущен на {{count}} хост(ах)', + 'task.confirm.execute.account': 'Учётная запись: {{account}}', + 'task.confirm.execute.script': 'Первая строка: {{script}}', + 'task.action.start': 'Старт', + 'task.action.pause': 'Пауза', + 'task.action.cancel': 'Отменить', + 'task.action.kill': 'Завершить', + 'task.action.ignore': 'Игнорировать', + 'task.action.redo': 'Повторить', + 'task.confirm.cancel': 'Отменить задачу? Незапущенные хосты будут пропущены', + 'task.confirm.kill': 'Завершить? Запущенные процессы будут принудительно остановлены', + 'task.confirm.host.kill': 'Завершить выполнение на этом хосте?', 'last.1.days': 'Последние 1 дней', 'last.7.days': 'Последние 7 дней', 'last.14.days': 'Последние 14 дней', diff --git a/src/locales/common/locale/zh_CN.ts b/src/locales/common/locale/zh_CN.ts index aa90befb3..76e115556 100644 --- a/src/locales/common/locale/zh_CN.ts +++ b/src/locales/common/locale/zh_CN.ts @@ -223,7 +223,7 @@ const zh_CN = { 'tpl.callback': '告警自愈回调地址', 'tpl.allOptionLabel': '全部脚本', - task: '自愈任务', + task: '执行历史', 'task.create': '创建任务', 'task.title': '标题', 'task.done': '是否完成', @@ -253,6 +253,20 @@ const zh_CN = { 'task.tip.content': '如果你的角色是管理员,则可以在任意机器执行脚本;否则,只能对有管理权限的业务组下的机器执行脚本', 'task.allOptionLabel': '全部任务', 'task.error.no_group': '业务组不存在无法创建任务,请联系管理员', + 'task.host.label': '执行机器', + 'task.confirm.execute.title': '确认立刻执行?', + 'task.confirm.execute.hosts': '将在 {{count}} 台主机上执行脚本', + 'task.confirm.execute.account': '执行账号:{{account}}', + 'task.confirm.execute.script': '脚本首行:{{script}}', + 'task.action.start': '开始', + 'task.action.pause': '暂停', + 'task.action.cancel': '取消执行', + 'task.action.kill': '强制终止', + 'task.action.ignore': '忽略', + 'task.action.redo': '重跑', + 'task.confirm.cancel': '确认取消该任务?未开始的机器将不再执行', + 'task.confirm.kill': '确认强制终止?正在执行的进程会被 kill', + 'task.confirm.host.kill': '确认强制终止该主机上的执行?', 'last.1.days': '最近 1 天', 'last.7.days': '最近 7 天', diff --git a/src/locales/common/locale/zh_HK.ts b/src/locales/common/locale/zh_HK.ts index 3a6a14014..c17c6d2a3 100644 --- a/src/locales/common/locale/zh_HK.ts +++ b/src/locales/common/locale/zh_HK.ts @@ -222,7 +222,7 @@ const zh_HK = { 'tpl.callback': '警告自癒回呼地址', 'tpl.allOptionLabel': '全部腳本', - task: '自癒任務', + task: '執行歷史', 'task.create': '創建任務', 'task.title': '標題', 'task.done': '是否完成', @@ -252,6 +252,20 @@ const zh_HK = { 'task.tip.content': '如果你的角色是管理員,則可以在任意機器執行腳本;否則,只能對有管理權限的業務組下的機器執行腳本', 'task.allOptionLabel': '全部任務', 'task.error.no_group': '業務組不存在無法建立任務,請聯絡管理員', + 'task.host.label': '執行機器', + 'task.confirm.execute.title': '確認立刻執行?', + 'task.confirm.execute.hosts': '將在 {{count}} 台主機上執行腳本', + 'task.confirm.execute.account': '執行帳號:{{account}}', + 'task.confirm.execute.script': '腳本首行:{{script}}', + 'task.action.start': '開始', + 'task.action.pause': '暫停', + 'task.action.cancel': '取消執行', + 'task.action.kill': '強制終止', + 'task.action.ignore': '忽略', + 'task.action.redo': '重跑', + 'task.confirm.cancel': '確認取消該任務?未開始的機器將不再執行', + 'task.confirm.kill': '確認強制終止?正在執行的進程會被 kill', + 'task.confirm.host.kill': '確認強制終止該主機上的執行?', 'last.1.days': '最近 1 天', 'last.7.days': '最近 7 天', diff --git a/src/pages/alertRules/locale/en_US.ts b/src/pages/alertRules/locale/en_US.ts index 424d06783..06cf64d89 100644 --- a/src/pages/alertRules/locale/en_US.ts +++ b/src/pages/alertRules/locale/en_US.ts @@ -300,12 +300,12 @@ const en_US = { }, }, task_tpls: { - title: 'Self-healing template', + title: 'Self-healing', add_btn: 'Create', - tpl_id: 'Template', - tpl_id_msg: 'Template is required', + tpl_id: 'Self-healing script', + tpl_id_msg: 'Self-healing script is required', host: 'Host', - host_placeholder: 'Optional, the host to be executed will be obtained from the ident tag in the event', + host_placeholder: 'Optional; the host is taken from the event\'s ident tag. If the event has no ident (e.g. some query-type alerts), it will silently not run', }, trigger: { title: 'Threshold Judgment', diff --git a/src/pages/alertRules/locale/ja_JP.ts b/src/pages/alertRules/locale/ja_JP.ts index 6e3a2f39a..d03994555 100644 --- a/src/pages/alertRules/locale/ja_JP.ts +++ b/src/pages/alertRules/locale/ja_JP.ts @@ -299,11 +299,11 @@ const ja_JP = { }, task_tpls: { title: 'アラート自己治癒', - add_btn: '自己治癒テンプレートを作成', - tpl_id: '自己治癒テンプレート', - tpl_id_msg: '自己治癒テンプレートは空にできません', + add_btn: '自己治癒スクリプトを作成', + tpl_id: '自己治癒スクリプト', + tpl_id_msg: '自己治癒スクリプトは空にできません', host: '実行マシン', - host_placeholder: 'デフォルトは空にできます。イベントの ident ラベルから実行するマシンを取得します', + host_placeholder: '空欄可。イベントの ident ラベルから実行マシンを取得します。イベントに ident がない場合(一部のクエリ型アラートなど)は何も実行されません', }, trigger: { title: '閾値判定', diff --git a/src/pages/alertRules/locale/ru_RU.ts b/src/pages/alertRules/locale/ru_RU.ts index 14cc927ae..a47112916 100644 --- a/src/pages/alertRules/locale/ru_RU.ts +++ b/src/pages/alertRules/locale/ru_RU.ts @@ -300,11 +300,11 @@ const ru_RU = { }, task_tpls: { title: 'Самовосстановление оповещения', - add_btn: 'Создать шаблон самовосстановления', - tpl_id: 'Шаблон самовосстановления', - tpl_id_msg: 'Шаблон самовосстановления не может быть пустым', + add_btn: 'Создать скрипт самовосстановления', + tpl_id: 'Скрипт самовосстановления', + tpl_id_msg: 'Скрипт самовосстановления не может быть пустым', host: 'Исполняющий хост', - host_placeholder: 'По умолчанию можно оставить пустым, хост для выполнения будет получен из метки ident в событии', + host_placeholder: 'Можно оставить пустым; хост берётся из метки ident события. Если у события нет ident (напр. некоторые query-оповещения), запуск молча не произойдёт', }, trigger: { title: 'Пороговая проверка', diff --git a/src/pages/alertRules/locale/zh_CN.ts b/src/pages/alertRules/locale/zh_CN.ts index d4dffba3b..6f0f3797a 100644 --- a/src/pages/alertRules/locale/zh_CN.ts +++ b/src/pages/alertRules/locale/zh_CN.ts @@ -299,11 +299,11 @@ const zh_CN = { }, task_tpls: { title: '告警自愈', - add_btn: '创建自愈模板', - tpl_id: '自愈模板', - tpl_id_msg: '自愈模板不能为空', + add_btn: '创建自愈脚本', + tpl_id: '自愈脚本', + tpl_id_msg: '自愈脚本不能为空', host: '执行机器', - host_placeholder: '默认可留空,会从事件中的 ident 标签获取要执行的机器', + host_placeholder: '默认可留空,会从事件的 ident 标签获取要执行的机器;若事件没有 ident(如部分查询类告警),将静默不执行', }, trigger: { title: '阈值判断', diff --git a/src/pages/alertRules/locale/zh_HK.ts b/src/pages/alertRules/locale/zh_HK.ts index 7b2c47eda..66acade72 100644 --- a/src/pages/alertRules/locale/zh_HK.ts +++ b/src/pages/alertRules/locale/zh_HK.ts @@ -298,12 +298,12 @@ const zh_HK = { }, }, task_tpls: { - title: '自愈模板', - add_btn: '創建自愈模板', - tpl_id: '自愈模板', - tpl_id_msg: '自愈模板不能為空', + title: '告警自愈', + add_btn: '創建自愈腳本', + tpl_id: '自愈腳本', + tpl_id_msg: '自愈腳本不能為空', host: '機器', - host_placeholder: '預設可留空,會從事件中的 ident 標籤取得要執行的機器', + host_placeholder: '預設可留空,會從事件的 ident 標籤取得要執行的機器;若事件沒有 ident(如部分查詢類告警),將靜默不執行', }, trigger: { title: '閾值判斷', diff --git a/src/pages/event/locale/en_US.ts b/src/pages/event/locale/en_US.ts index 6827d5006..849e28ec0 100644 --- a/src/pages/event/locale/en_US.ts +++ b/src/pages/event/locale/en_US.ts @@ -94,7 +94,7 @@ const en_US = { alert_aggr_events_link: 'Aggregated events', }, task_tpls: { - label: 'Self-healing template', + label: 'Self-healing script', }, step_tip: 'Time interval of the curve, in seconds', query_label: 'Query', diff --git a/src/pages/event/locale/ja_JP.ts b/src/pages/event/locale/ja_JP.ts index d3b44d250..6dbc6dcda 100644 --- a/src/pages/event/locale/ja_JP.ts +++ b/src/pages/event/locale/ja_JP.ts @@ -92,7 +92,7 @@ const ja_JP = { alert_aggr_events_link: '集約イベント', }, task_tpls: { - label: '自動回復テンプレート', + label: '自己治癒スクリプト', }, step_tip: 'グラフの時間間隔、単位は秒', query_label: 'クエリ条件', diff --git a/src/pages/event/locale/ru_RU.ts b/src/pages/event/locale/ru_RU.ts index d41c2dd83..abd64fdfd 100644 --- a/src/pages/event/locale/ru_RU.ts +++ b/src/pages/event/locale/ru_RU.ts @@ -92,7 +92,7 @@ const ru_RU = { alert_aggr_events_link: 'Собранные события', }, task_tpls: { - label: 'Шаблоны самовосстановления', + label: 'Скрипты самовосстановления', }, step_tip: 'Интервал времени кривой в секундах', query_label: 'Условия запроса', diff --git a/src/pages/event/locale/zh_CN.ts b/src/pages/event/locale/zh_CN.ts index 0208de25a..0190cefd5 100644 --- a/src/pages/event/locale/zh_CN.ts +++ b/src/pages/event/locale/zh_CN.ts @@ -94,7 +94,7 @@ const zh_CN = { alert_aggr_events_link: '聚合事件', }, task_tpls: { - label: '自愈模板', + label: '自愈脚本', }, step_tip: '曲线时间间隔,单位秒', query_label: '查询条件', diff --git a/src/pages/event/locale/zh_HK.ts b/src/pages/event/locale/zh_HK.ts index 541efac1b..1568a5a38 100644 --- a/src/pages/event/locale/zh_HK.ts +++ b/src/pages/event/locale/zh_HK.ts @@ -92,7 +92,7 @@ const zh_HK = { alert_aggr_events_link: '聚合事件', }, task_tpls: { - label: '自癒模板', + label: '自愈腳本', }, step_tip: '曲線時間間隔,單位秒', query_label: '查詢條件', diff --git a/src/pages/task/MetaDrawer/index.tsx b/src/pages/task/MetaDrawer/index.tsx index cd10a2c87..e6d75eca6 100644 --- a/src/pages/task/MetaDrawer/index.tsx +++ b/src/pages/task/MetaDrawer/index.tsx @@ -30,6 +30,8 @@ interface Props { data: any; hosts: { host: string }[]; taskId: string; + /** 任务所属业务组,克隆时需带上,否则「全部任务」视图下会用当前选中组请求到错误的 busi-group */ + busiId?: number; hideCloneTask?: boolean; } @@ -44,7 +46,7 @@ type SizeType = 'small' | 'middle' | 'large'; export default function MetaDrawer(props: Props) { const { t } = useTranslation('common'); const { t: tn } = useTranslation('navigableDrawer'); - const { visible, loading = false, onClose, data, hosts, taskId, hideCloneTask } = props; + const { visible, loading = false, onClose, data, hosts, taskId, busiId, hideCloneTask } = props; const [size, setSize] = useState('middle'); return ( @@ -67,7 +69,7 @@ export default function MetaDrawer(props: Props) { } footer={ !hideCloneTask && ( - + ) diff --git a/src/pages/task/OutputDrawer/index.tsx b/src/pages/task/OutputDrawer/index.tsx index 5f6299c81..04d47958b 100644 --- a/src/pages/task/OutputDrawer/index.tsx +++ b/src/pages/task/OutputDrawer/index.tsx @@ -14,13 +14,16 @@ * limitations under the License. * */ -import React, { useEffect, useState } from 'react'; -import { Drawer, Segmented, Spin } from 'antd'; +import React, { useEffect, useMemo, useState } from 'react'; +import { Drawer, Segmented, Spin, Input, Switch, Button, Space } from 'antd'; +import { CopyOutlined, DownloadOutlined } from '@ant-design/icons'; +import { useDebounce } from 'ahooks'; import _ from 'lodash'; import { useTranslation } from 'react-i18next'; import request from '@/utils/request'; import api from '@/utils/api'; +import { copyToClipBoard, download } from '@/utils'; interface Props { visible: boolean; @@ -43,6 +46,7 @@ type SizeType = 'small' | 'middle' | 'large'; export default function OutputDrawer(props: Props) { const { t } = useTranslation('navigableDrawer'); + const { t: tsh } = useTranslation('alertSelfHealing'); const { visible, onClose, busiId, taskId, host, outputType, title, onOutputClose } = props; const handleClose = () => { @@ -52,24 +56,36 @@ export default function OutputDrawer(props: Props) { const [loading, setLoading] = useState(false); const [data, setData] = useState(null); const [size, setSize] = useState('middle'); + const [search, setSearch] = useState(''); + const debouncedSearch = useDebounce(search, { wait: 300 }); + const [wrap, setWrap] = useState(true); useEffect(() => { if (!visible) return; + // 抽屉复用同一实例、只切 visible,重新拉取前先清空上一台主机的数据与搜索词。 + // stale 守卫:主机 A 响应慢、切到主机 B 时,A 的迟到响应不再覆盖 B 的数据 + let stale = false; + setData(null); + setSearch(''); setLoading(true); const url = host ? `${api.task(busiId)}/${taskId}/host/${host}/${outputType}` : `${api.task(busiId)}/${taskId}/${outputType}`; request(url) .then((res) => { - setData(res.dat); + if (!stale) setData(res.dat); }) .catch(() => { - setData(null); + if (!stale) setData(null); }) .finally(() => { - setLoading(false); + if (!stale) setLoading(false); }); + return () => { + stale = true; + }; }, [visible, busiId, taskId, host, outputType]); - const getOutput = () => { + // 「全部主机」模式下输出可达数 MB,缓存拼接结果,避免每次渲染 / 每敲一键都全量重拼 + const fullOutput = useMemo(() => { if (!data) return ''; if (host) { return `${host}\n${data}\n\n`; @@ -80,6 +96,23 @@ export default function OutputDrawer(props: Props) { output += `${item[outputType]}\n\n`; }); return output; + }, [data, host, outputType]); + + // 搜索按行过滤(类 grep),用防抖后的关键词并缓存结果,关键词小写只算一次 + const displayed = useMemo(() => { + if (!debouncedSearch) return fullOutput; + const kw = _.toLower(debouncedSearch); + return _.filter(_.split(fullOutput, '\n'), (line) => _.includes(_.toLower(line), kw)).join('\n'); + }, [fullOutput, debouncedSearch]); + + // 复用仓库的 copyToClipBoard(基于 execCommand,纯 HTTP 内网也可用,且自带成功/失败提示) + const handleCopy = () => { + copyToClipBoard(fullOutput); + }; + + // 复用仓库的 download(appendChild 后 click、不做同步 revoke,Firefox/Safari 下更稳) + const handleDownload = () => { + download(fullOutput, `${_.replace(title, /[\\/:*?"<>|\s]+/g, '_') || 'output'}.log`); }; return ( @@ -102,7 +135,30 @@ export default function OutputDrawer(props: Props) { } > -
{getOutput()}
+
+ setSearch(e.target.value)} /> + + + {tsh('result.wrap')} + + + +
+
+          {displayed}
+        
); diff --git a/src/pages/task/ResultContent/index.tsx b/src/pages/task/ResultContent/index.tsx index c43740c75..b894516a6 100644 --- a/src/pages/task/ResultContent/index.tsx +++ b/src/pages/task/ResultContent/index.tsx @@ -16,8 +16,9 @@ */ import React, { useEffect, useState } from 'react'; import { Link } from 'react-router-dom'; -import { Divider, Tag, Row, Col, Button, Card } from 'antd'; +import { Divider, Tag, Row, Col, Button, Card, Modal, message } from 'antd'; import { ColumnProps } from 'antd/lib/table'; +import classnames from 'classnames'; import _ from 'lodash'; import { useTranslation } from 'react-i18next'; @@ -47,8 +48,22 @@ interface Props { const taskResultCls = 'job-task-result'; +const FAILED_STATUSES = ['failed', 'killfailed', 'timeout']; + +// 一键重试的单批并发数,批与批之间串行 +const RETRY_BATCH_SIZE = 10; + +// 主机状态标签色:成功 / 中性(取消、忽略)/ 失败,供状态列与概况条共用 +function statusColor(status: string): string { + if (status === 'success') return '#87d068'; + if (status === 'cancelled' || status === 'ignored') return '#ec971f'; + if (_.includes(FAILED_STATUSES, status)) return '#f50'; + return ''; +} + const ResultContent: React.FC = ({ taskId, busiId, hideCloneTask, metaAlias, initialOutputMode, onOutputOpen, onOutputClose }) => { const { t } = useTranslation('common'); + const { t: tsh } = useTranslation('alertSelfHealing'); const [activeStatus, setActiveStatus] = useState(); const [data, setData] = useState({} as any); const [hosts, setHosts] = useState([]); @@ -78,7 +93,7 @@ const ResultContent: React.FC = ({ taskId, busiId, hideCloneTask, metaAli useEffect(() => { getTableData(); - }, []); + }, [taskId, busiId]); useEffect(() => { if (initialOutputMode) { @@ -110,6 +125,21 @@ const ResultContent: React.FC = ({ taskId, busiId, hideCloneTask, metaAli }); }; + // 单机强杀前二次确认,其余动作直接执行 + const confirmHostAction = (host: string, action: string) => { + if (action === 'kill') { + Modal.confirm({ + title: t('task.confirm.host.kill'), + okButtonProps: { danger: true }, + okText: t('task.action.kill'), + cancelText: t('common:btn.cancel'), + onOk: () => handleHostAction(host, action), + }); + return; + } + handleHostAction(host, action); + }; + const handleTaskAction = (action: string) => { request(`${api.task(busiId)}/${taskId}/action`, { method: 'PUT', @@ -121,8 +151,61 @@ const ResultContent: React.FC = ({ taskId, busiId, hideCloneTask, metaAli }); }; + // 取消执行 / 强制终止会影响正在运行的任务,执行前二次确认 + const confirmTaskAction = (action: string) => { + if (action === 'cancel' || action === 'kill') { + Modal.confirm({ + title: action === 'kill' ? t('task.confirm.kill') : t('task.confirm.cancel'), + okButtonProps: { danger: true }, + okText: action === 'kill' ? t('task.action.kill') : t('task.action.cancel'), + cancelText: t('common:btn.cancel'), + onOk: () => handleTaskAction(action), + }); + return; + } + handleTaskAction(action); + }; + + const groupedHosts = _.groupBy(hosts, 'status'); + const failedHosts = _.filter(hosts, (h) => _.includes(FAILED_STATUSES, h.status)); + + // 一键重试所有失败 / 超时的主机,避免逐台点 redo + const retryFailedHosts = () => { + if (_.isEmpty(failedHosts)) { + message.info(tsh('result.no_failed')); + return; + } + Modal.confirm({ + title: tsh('result.retry_failed_confirm', { count: failedHosts.length }), + onOk: async () => { + let failedCount = 0; + // 主机列表是全量返回的(可达上千台),分批串行发起,避免一次点击打爆浏览器连接池; + // 批内用 allSettled:单台 redo 失败不应吞掉其余已成功的重试 + for (const chunk of _.chunk(failedHosts, RETRY_BATCH_SIZE)) { + const results = await Promise.allSettled( + _.map(chunk, (h) => + request(`${api.task(busiId)}/${taskId}/host/${h.host}/action`, { + method: 'PUT', + body: JSON.stringify({ action: 'redo' }), + }), + ), + ); + failedCount += _.filter(results, (r) => r.status === 'rejected').length; + } + if (failedCount > 0) { + message.error(tsh('result.retry_partial_failed', { count: failedCount })); + } + getTableData(); + }, + }); + }; + + // 点击概况条上的状态徽标,切换表格按该状态过滤(再次点击取消) + const toggleStatusFilter = (status: string) => { + setActiveStatus((prev) => (prev?.length === 1 && prev[0] === status ? undefined : [status])); + }; + const renderHostStatusFilter = () => { - const groupedHosts = _.groupBy(hosts, 'status'); return _.map(groupedHosts, (chosts, status) => { return { text: `${status} (${chosts.length})`, @@ -140,18 +223,13 @@ const ResultContent: React.FC = ({ taskId, busiId, hideCloneTask, metaAli title: t('task.status'), dataIndex: 'status', filters: renderHostStatusFilter(), + filteredValue: activeStatus ?? null, onFilter: (value: string, record) => { return record.status === value; }, render: (text) => { - if (text === 'success') { - return {text}; - } else if (text === 'cancelled' || text === 'ignored') { - return {text}; - } else if (text === 'failed' || text === 'killfailed' || text === 'timeout') { - return {text}; - } - return {text}; + const color = statusColor(text); + return color ? {text} : {text}; }, }, { @@ -191,12 +269,39 @@ const ResultContent: React.FC = ({ taskId, busiId, hideCloneTask, metaAli { getTableData(); }} /> } > + {!_.isEmpty(hosts) && ( +
+ {tsh('result.status_bar')}: + {_.map(groupedHosts, (list, status) => { + const color = statusColor(status); + const active = activeStatus?.length === 1 && activeStatus[0] === status; + return ( + toggleStatusFilter(status)} + > + {status} {list.length} + + ); + })} + {!data.done && failedHosts.length > 0 && ( + + )} +
+ )}
@@ -220,26 +325,26 @@ const ResultContent: React.FC = ({ taskId, busiId, hideCloneTask, metaAli setMetaDrawerVisible(true)}>{metaAlias ?? t('task.meta')} {!hideCloneTask && } - {!hideCloneTask && {t('task.clone')}} + {!hideCloneTask && {t('task.clone')}}
{!data.done ? ( {data.action === 'start' ? ( - ) : ( - )} - - ) : null} @@ -255,9 +360,9 @@ const ResultContent: React.FC = ({ taskId, busiId, hideCloneTask, metaAli ? { rowActions: (record) => ({ inline: [ - { key: 'ignore', icon: 'default', text: 'ignore', onClick: () => handleHostAction(record.host, 'ignore') }, - { key: 'redo', icon: 'run', text: 'redo', onClick: () => handleHostAction(record.host, 'redo') }, - { key: 'kill', icon: 'delete', text: 'kill', danger: true, onClick: () => handleHostAction(record.host, 'kill') }, + { key: 'ignore', icon: 'default', text: t('task.action.ignore'), onClick: () => handleHostAction(record.host, 'ignore') }, + { key: 'redo', icon: 'run', text: t('task.action.redo'), onClick: () => handleHostAction(record.host, 'redo') }, + { key: 'kill', icon: 'delete', text: t('task.action.kill'), danger: true, onClick: () => confirmHostAction(record.host, 'kill') }, ], }), actionColumn: { title: t('table.operations'), width: 110 }, @@ -290,7 +395,7 @@ const ResultContent: React.FC = ({ taskId, busiId, hideCloneTask, metaAli title={`${data.title} - ${outputDrawer.host ? `${outputDrawer.host} - ` : ''}${outputDrawer.outputType}`} onOutputClose={onOutputClose} /> - setMetaDrawerVisible(false)} data={data} hosts={hosts} taskId={taskId} hideCloneTask={hideCloneTask} /> + setMetaDrawerVisible(false)} data={data} hosts={hosts} taskId={taskId} busiId={busiId} hideCloneTask={hideCloneTask} /> ); }; diff --git a/src/pages/task/ResultDrawer/index.tsx b/src/pages/task/ResultDrawer/index.tsx deleted file mode 100644 index 4363335d9..000000000 --- a/src/pages/task/ResultDrawer/index.tsx +++ /dev/null @@ -1,81 +0,0 @@ -/* - * Copyright 2022 Nightingale Team - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * - */ -import React, { useState } from 'react'; -import { Drawer, Segmented } from 'antd'; -import { useTranslation } from 'react-i18next'; - -import ResultContent from '../ResultContent'; - -interface Props { - title?: string; - visible: boolean; - onClose: () => void; - taskId: string; - busiId: number; - hideCloneTask?: boolean; - metaAlias?: string; - initialOutputMode?: { outputType: 'stdout' | 'stderr'; host?: string }; - onOutputOpen?: (info: { outputType: 'stdout' | 'stderr'; host?: string }) => void; - onOutputClose?: (info: { outputType: 'stdout' | 'stderr'; host?: string }) => void; -} - -const sizeWidthMap = { - small: '35%', - middle: '55%', - large: '75%', -}; - -type SizeType = 'small' | 'middle' | 'large'; - -export default function ResultDrawer(props: Props) { - const { t } = useTranslation('navigableDrawer'); - const { title, visible, onClose, taskId, busiId, hideCloneTask, metaAlias, initialOutputMode, onOutputOpen, onOutputClose } = props; - const [size, setSize] = useState('middle'); - - return ( - setSize(value as SizeType)} - /> - } - > - {visible && ( - - )} - - ); -} diff --git a/src/pages/task/add.tsx b/src/pages/task/add.tsx index 9f57a9d7e..2c17e6ba1 100644 --- a/src/pages/task/add.tsx +++ b/src/pages/task/add.tsx @@ -15,8 +15,8 @@ * */ import React, { useState, useEffect, useContext, useRef } from 'react'; -import { Button, Spin, Row, Col, Card, Alert, message } from 'antd'; -import { RollbackOutlined } from '@ant-design/icons'; +import { Button, Spin, Row, Col, Card, Alert, Modal, message } from 'antd'; +import { RollbackOutlined, ExclamationCircleOutlined } from '@ant-design/icons'; import { useHistory } from 'react-router-dom'; import _ from 'lodash'; import queryString from 'query-string'; @@ -40,26 +40,54 @@ const Add = (props: any) => { const [action, setAction] = useState(''); const groupId = useRef(Number(curBusiId)); + const doSubmit = (values: any) => { + request(api.tasks(groupId.current), { + method: 'POST', + body: JSON.stringify({ + ...values, + action, + }), + }).then((res) => { + message.success(t('msg.create.success')); + // 携带创建时实际使用的组 id(可能来自模板/克隆源),与列表页跳转保持一致, + // 避免「全部」(-2) 视图下结果页用全局 businessGroup.id 请求到错误的 busi-group + props.history.push({ + pathname: `/job-tasks/${res.dat}/result`, + search: `gid=${groupId.current}`, + }); + }); + }; + const handleSubmit = (values: any) => { if (!groupId.current) { message.error(t('task.error.no_group')); return; } - if (action) { - values.pause = _.join(values.pause, ','); - request(api.tasks(groupId.current), { - method: 'POST', - body: JSON.stringify({ - ...values, - action, - }), - }).then((res) => { - message.success(t('msg.create.success')); - props.history.push({ - pathname: `/job-tasks/${res.dat}/result`, - }); + if (!action) { + return; + } + // 「保存立刻执行」会立即在多台机器上跑 shell,执行前二次确认(保存暂不执行不弹) + if (action === 'start') { + const hostCount = _.size(values.hosts); + const scriptFirstLine = _.trim(_.head(_.split(values.script || '', '\n')) || ''); + Modal.confirm({ + title: t('task.confirm.execute.title'), + icon: , + content: ( +
+
{t('task.confirm.execute.hosts', { count: hostCount })}
+
{t('task.confirm.execute.account', { account: values.account })}
+
{t('task.confirm.execute.script', { script: scriptFirstLine })}
+
+ ), + okText: t('task.save.execute'), + okButtonProps: { danger: true }, + cancelText: t('common:btn.cancel'), + onOk: () => doSubmit(values), }); + return; } + doSubmit(values); }; useEffect(() => { diff --git a/src/pages/task/detail.tsx b/src/pages/task/detail.tsx deleted file mode 100644 index 7fa8a3486..000000000 --- a/src/pages/task/detail.tsx +++ /dev/null @@ -1,145 +0,0 @@ -/* - * Copyright 2022 Nightingale Team - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * - */ -import React, { useState, useEffect, useContext } from 'react'; -import { Link, useHistory } from 'react-router-dom'; -import { Spin, Divider, Button, Card } from 'antd'; -import { RollbackOutlined } from '@ant-design/icons'; -import moment from 'moment'; -import _ from 'lodash'; -import { useTranslation } from 'react-i18next'; -import request from '@/utils/request'; -import api from '@/utils/api'; -import PageLayout from '@/components/pageLayout'; -import { CommonStateContext } from '@/App'; -import Editor from '../taskTpl/editor'; -import './style.less'; - -const Detail = (props: any) => { - const history = useHistory(); - const { businessGroup } = useContext(CommonStateContext); - const curBusiId = businessGroup.id!; - const { t } = useTranslation('common'); - const taskId = _.get(props, 'match.params.id'); - const [loading, setLoading] = useState(false); - const [data, setData] = useState({} as any); - - useEffect(() => { - if (taskId !== undefined) { - setLoading(true); - request(`${api.task(curBusiId)}/${taskId}`) - .then((data) => { - setData({ - ...data.dat.meta, - hosts: _.map(data.dat.hosts, (host) => { - return host.host; - }), - }); - }) - .finally(() => { - setLoading(false); - }); - } - }, [taskId]); - - return ( - - history.push('/job-tasks')} /> - {t('task')} - - } - > -
- - -
-
-
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
{t('task.title')}{data.title}
{t('task.creator')} - {data.creator} @ {moment(data.created).format('YYYY-MM-DD HH:mm:ss')} -
{t('task.control.params')} - {t('task.account')}:{data.account} - - {t('task.batch')}:{data.batch} - - {t('task.tolerance')}:{data.tolerance} - - {t('task.timeout')}:{data.timeout} -
{t('task.script')} - -
{t('task.script.args')}{data.args}
{t('task.pause')}{data.pause}
{t('task.host.list')} - {_.map(data.hosts, (host) => { - return
{host}
; - })} -
-
-
-
-
-
- - - - - - -
-
-
-
-
- ); -}; - -export default Detail; diff --git a/src/pages/task/index.tsx b/src/pages/task/index.tsx index b840a1f9b..f8fb15bb0 100644 --- a/src/pages/task/index.tsx +++ b/src/pages/task/index.tsx @@ -30,6 +30,7 @@ import api from '@/utils/api'; import { RequestMethod } from '@/store/common'; import PageLayout from '@/components/pageLayout'; import BlankBusinessPlaceholder from '@/components/BlankBusinessPlaceholder'; +import EmptyGuide from '@/components/EmptyGuide'; import { CommonStateContext } from '@/App'; import BusinessGroupSideBarWithAll, { getDefaultGids } from '@/components/BusinessGroup/BusinessGroupSideBarWithAll'; import RefreshIcon from '@/components/RefreshIcon'; @@ -79,6 +80,7 @@ function getTableData(options: any, gids: string | undefined, query: string, min const index = (_props: any) => { const history = useHistory(); const { t } = useTranslation('common'); + const { t: tsh } = useTranslation('alertSelfHealing'); const defaultFilter = getDefaultFilter(); const [query, setQuery] = useState(defaultFilter.query || ''); const [mine, setMine] = useState(defaultFilter.mine !== undefined ? defaultFilter.mine : true); @@ -91,6 +93,7 @@ const index = (_props: any) => { const [metaDrawerData, setMetaDrawerData] = useState({}); const [metaDrawerHosts, setMetaDrawerHosts] = useState([]); const [metaDrawerTaskId, setMetaDrawerTaskId] = useState(''); + const [metaDrawerBusiId, setMetaDrawerBusiId] = useState(); const pagination = usePagination({ PAGESIZE_KEY: 'job-tasks-pagesize' }); useEffect(() => { @@ -106,11 +109,13 @@ const index = (_props: any) => { const handleOpenMetaDrawer = (record: any) => { setMetaDrawerTaskId(String(record.id)); + setMetaDrawerBusiId(record.group_id); setMetaDrawerData({}); setMetaDrawerHosts([]); setMetaDrawerLoading(true); setMetaDrawerVisible(true); - request(`${api.task(businessGroup.id!)}/${record.id}`) + // 用行自身的 group_id,避免「全部任务」(-2) 视图下 busiId 取到当前选中组而定位错误 + request(`${api.task(record.group_id)}/${record.id}`) .then((data) => { setMetaDrawerData({ ...data.dat.meta, @@ -134,7 +139,7 @@ const index = (_props: any) => { const groupName = _.find(busiGroups, { id: record.group_id })?.name; return (
- {text} + {text} ID: {record.id} {showBusinessGroup && groupName && {groupName}} @@ -145,7 +150,19 @@ const index = (_props: any) => { }, ] as any, [ - dateColumn({ title: t('task.created'), dataIndex: 'create_at', unix: true, sortable: true }), + { + // 触发来源:event_id 非 0 即由告警自动触发,可跳到对应事件 + title: tsh('source.title'), + dataIndex: 'event_id', + width: 110, + render: (eventId: number) => + eventId ? ( + {tsh('source.alert')} + ) : ( + {tsh('source.manual')} + ), + }, + dateColumn({ title: t('task.created'), dataIndex: 'create_at', unix: true }), updateByColumn({ title: t('task.creator'), dataIndex: 'create_by', nickname: 'create_by_nickname' }), ] as any, ); @@ -222,13 +239,35 @@ const index = (_props: any) => { key: 'clone', icon: 'copy', text: t('task.clone'), - onClick: () => history.push({ pathname: '/job-tasks/add', search: `task=${record.id}` }), + onClick: () => history.push({ pathname: '/job-tasks/add', search: `task=${record.id}&gid=${record.group_id}` }), }, { key: 'meta', icon: 'view', text: t('task.meta'), onClick: () => handleOpenMetaDrawer(record) }, ], })} actionColumn={{ title: t('table.operations'), width: 64 }} {...(tableProps as any)} + locale={{ + // 区分「筛选没命中」与「真的没有」:默认只看自己 / 近 7 天常导致新用户看到空列表 + emptyText: _.isEmpty(tableProps.dataSource) ? ( + { + setMine(false); + setQuery(''); + setDays(90); + }} + > + {tsh('task_empty.reset_btn')} + + ) : undefined + } + /> + ) : undefined, + }} pagination={{ ...pagination, ...tableProps.pagination, @@ -246,6 +285,7 @@ const index = (_props: any) => { data={metaDrawerData} hosts={metaDrawerHosts} taskId={metaDrawerTaskId} + busiId={metaDrawerBusiId} /> ); diff --git a/src/pages/task/result.tsx b/src/pages/task/result.tsx index 9083f2934..268980efc 100644 --- a/src/pages/task/result.tsx +++ b/src/pages/task/result.tsx @@ -18,6 +18,7 @@ import React, { useContext } from 'react'; import { useHistory } from 'react-router-dom'; import { RollbackOutlined } from '@ant-design/icons'; import { useTranslation } from 'react-i18next'; +import queryString from 'query-string'; import PageLayout from '@/components/pageLayout'; import { CommonStateContext } from '@/App'; @@ -27,7 +28,9 @@ import ResultContent from './ResultContent'; const index = (props: any) => { const history = useHistory(); const { businessGroup } = useContext(CommonStateContext); - const curBusiId = businessGroup.id!; + const query = queryString.parse(props.location?.search); + // 优先用列表页传来的 gid,避免「全部任务」(-2) 视图下取当前选中组导致越权/定位错误 + const curBusiId = Number(query.gid) || businessGroup.id!; const { params } = props.match; const taskId = params.id; const { t } = useTranslation('common'); diff --git a/src/pages/task/style.less b/src/pages/task/style.less index 5f7acf664..36efe02b3 100644 --- a/src/pages/task/style.less +++ b/src/pages/task/style.less @@ -22,43 +22,4 @@ .active { text-decoration: underline; } - - .success-btn { - background: #87d068; - color: #fff; - border: 0 none; - - &:hover, - &:active, - &:focus { - background: #449d44; - color: #fff; - } - } - - .warning-btn { - background: #f0ad4e; - color: #fff; - border: 0 none; - - &:hover, - &:active, - &:focus { - background: #ec971f; - color: #fff; - } - } - - .danger-btn { - background: #d9534f; - color: #fff; - border: 0 none; - - &:hover, - &:active, - &:focus { - background: #c9302c; - color: #fff; - } - } } \ No newline at end of file diff --git a/src/pages/taskOutput/host.jsx b/src/pages/taskOutput/host.jsx deleted file mode 100644 index b381ed820..000000000 --- a/src/pages/taskOutput/host.jsx +++ /dev/null @@ -1,64 +0,0 @@ -import React, { Component } from 'react'; -import { Spin } from 'antd'; -import _ from 'lodash'; -import request from '@/utils/request'; -import api from '@/utils/api'; - -export default class index extends Component { - constructor(props) { - super(props); - this.state = { - loading: false, - data: '', - }; - } - - componentDidMount = () => { - this.fetchData(); - document.title = '任务输出'; - document.body.style.backgroundColor = '#fff'; - document.body.style.color = '#000'; - }; - - componentWillMount = () => { - document.body.style.backgroundColor = '#f0f2f5'; - document.body.style.color = 'rgba(0, 0, 0, 0.65)'; - }; - - fetchData() { - const busiId = _.get(this.props, 'match.params.busiId'); - const taskId = _.get(this.props, 'match.params.taskId'); - const host = _.get(this.props, 'match.params.host'); - const outputType = _.get(this.props, 'match.params.outputType'); - - if (taskId !== undefined && host !== undefined) { - this.setState({ loading: true }); - request(`${api.task(busiId)}/${taskId}/host/${host}/${outputType}`) - .then((data) => { - this.setState({ data: data.dat }); - }) - .finally(() => { - this.setState({ loading: false }); - }); - } - } - - getOutput() { - const host = _.get(this.props, 'match.params.host'); - const { data } = this.state; - let output = ''; - output += `${host}\n`; - output += `${data}\n\n`; - return output; - } - - render() { - const { loading } = this.state; - const output = this.getOutput(); - return ( - -
{output}
-
- ); - } -} diff --git a/src/pages/taskOutput/index.jsx b/src/pages/taskOutput/index.jsx deleted file mode 100644 index 68d8dcd58..000000000 --- a/src/pages/taskOutput/index.jsx +++ /dev/null @@ -1,65 +0,0 @@ -import React, { Component } from 'react'; -import { Spin } from 'antd'; -import _ from 'lodash'; -import request from '@/utils/request'; -import api from '@/utils/api'; - -export default class index extends Component { - constructor(props) { - super(props); - this.state = { - loading: false, - data: [], - }; - } - - componentDidMount = () => { - this.fetchData(); - document.title = '任务输出'; - document.body.style.backgroundColor = '#fff'; - document.body.style.color = '#000'; - }; - - componentWillMount = () => { - document.body.style.backgroundColor = '#f0f2f5'; - document.body.style.color = 'rgba(0, 0, 0, 0.65)'; - }; - - fetchData() { - const busiId = _.get(this.props, 'match.params.busiId'); - const taskId = _.get(this.props, 'match.params.taskId'); - const outputType = _.get(this.props, 'match.params.outputType'); - - if (taskId !== undefined && outputType !== undefined) { - this.setState({ loading: true }); - request(`${api.task(busiId)}/${taskId}/${outputType}`) - .then((data) => { - this.setState({ data: data.dat }); - }) - .finally(() => { - this.setState({ loading: false }); - }); - } - } - - getOutput() { - const outputType = _.get(this.props, 'match.params.outputType'); - const { data } = this.state; - let output = ''; - _.each(data, (item) => { - output += `${item.host}\n`; - output += `${item[outputType]}\n\n`; - }); - return output; - } - - render() { - const { loading } = this.state; - const output = this.getOutput(); - return ( - -
{output}
-
- ); - } -} diff --git a/src/pages/taskTpl/add.tsx b/src/pages/taskTpl/add.tsx index f1be0d37e..44ad41304 100644 --- a/src/pages/taskTpl/add.tsx +++ b/src/pages/taskTpl/add.tsx @@ -15,7 +15,7 @@ * */ import React, { useContext } from 'react'; -import { Button, Card, message } from 'antd'; +import { Button, Card, Modal, message } from 'antd'; import { RollbackOutlined } from '@ant-design/icons'; import _ from 'lodash'; import { useHistory, useLocation } from 'react-router-dom'; @@ -24,9 +24,13 @@ import { useTranslation } from 'react-i18next'; import PageLayout from '@/components/pageLayout'; import request from '@/utils/request'; import api from '@/utils/api'; -import TplForm from './tplForm'; +import TplForm, { defaultInitialValues } from './tplForm'; +import ScenarioTips from './components/ScenarioTips'; +import { DOC_URL, SCRIPT_TEMPLATES, SCRIPT_TEMPLATES_ENABLED } from './constants'; import { CommonStateContext } from '@/App'; +const NS = 'alertSelfHealing'; + const Add = (props: any) => { const history = useHistory(); const location = useLocation(); @@ -34,15 +38,37 @@ const Add = (props: any) => { const { businessGroup } = useContext(CommonStateContext); const curBusiId = (query.gid as string) || businessGroup.id!; const { t } = useTranslation('common'); + const { t: tsh } = useTranslation(NS); + + // 从模板创建:query.tplkey 命中内置模板则预填脚本 / 参数 / 超时,标题给出模板名(可改) + // 模板库暂未开放,用 SCRIPT_TEMPLATES_ENABLED 收口,避免直接访问 ?tplkey= 触发隐藏功能 + const template = SCRIPT_TEMPLATES_ENABLED && query.tplkey ? _.find(SCRIPT_TEMPLATES, { key: query.tplkey as string }) : undefined; + const initialValues = template + ? { + ...defaultInitialValues, + group_id: _.toNumber(curBusiId), + title: tsh(`templates.${template.key}.title`), + script: template.script, + args: template.args, + timeout: template.timeout, + } + : { ...defaultInitialValues, group_id: _.toNumber(curBusiId) }; + const handleSubmit = (values: any) => { - values.pause = _.join(values.pause, ','); request(`${api.tasktpls(curBusiId)}`, { method: 'POST', body: JSON.stringify(values), }).then(() => { message.success(t('msg.create.success')); - // TODO: 这里返回列表页时需要获取参数里的 ids,不能用 history.push - window.location.href = `/job-tpls?ids=${curBusiId}&isLeaf=true`; + // 建完引导下一步:自愈脚本只有绑定到告警规则才会执行 + Modal.confirm({ + title: tsh('next_step.title'), + content: tsh('next_step.desc'), + okText: tsh('next_step.go_bind'), + cancelText: tsh('next_step.later'), + onOk: () => history.push('/alert-rules'), + onCancel: () => history.push('/job-tpls'), + }); }); }; @@ -54,12 +80,16 @@ const Add = (props: any) => { {t('tpl')} } - doc='https://flashcat.cloud/docs/content/flashcat-monitor/nightingale-v9/usage/alert-notify/self-healing/self-healing-script/' + doc={DOC_URL} >
+
+ +
diff --git a/src/pages/taskTpl/clone.tsx b/src/pages/taskTpl/clone.tsx index e64eb9488..9dd84c853 100644 --- a/src/pages/taskTpl/clone.tsx +++ b/src/pages/taskTpl/clone.tsx @@ -40,7 +40,6 @@ const Add = (props: any) => { const [data, setData] = useState({} as Tpl); const handleSubmit = (values: any) => { - values.pause = _.join(values.pause, ','); request(`${api.tasktpls(values.group_id)}`, { method: 'POST', body: JSON.stringify(values), diff --git a/src/pages/taskTpl/components/ScenarioList.tsx b/src/pages/taskTpl/components/ScenarioList.tsx new file mode 100644 index 000000000..cf71f7629 --- /dev/null +++ b/src/pages/taskTpl/components/ScenarioList.tsx @@ -0,0 +1,25 @@ +import React from 'react'; +import classnames from 'classnames'; +import { useTranslation } from 'react-i18next'; +import _ from 'lodash'; + +const ITEM_KEYS = ['disk', 'service', 'oom']; + +interface Props { + className?: string; +} + +/** 自愈脚本的三类典型场景,新建页的提示卡片与列表空状态共用同一份文案 */ +export default function ScenarioList({ className }: Props) { + const { t } = useTranslation('alertSelfHealing'); + + return ( +
    + {_.map(ITEM_KEYS, (key) => ( +
  • + {t(`scenario_tips.${key}`)} +
  • + ))} +
+ ); +} diff --git a/src/pages/taskTpl/components/ScenarioTips.tsx b/src/pages/taskTpl/components/ScenarioTips.tsx new file mode 100644 index 000000000..40c311b9f --- /dev/null +++ b/src/pages/taskTpl/components/ScenarioTips.tsx @@ -0,0 +1,55 @@ +import React, { useContext, useState } from 'react'; +import { CloseOutlined } from '@ant-design/icons'; +import { Lightbulb } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; + +import { CommonStateContext } from '@/App'; +import DocumentDrawer from '@/components/DocumentDrawer'; + +import { DOC_URL } from '../constants'; +import ScenarioList from './ScenarioList'; + +const STORAGE_KEY = 'alert_self_healing_scenario_tips_dismissed'; + +/** + * 新建自愈脚本时的场景说明卡片:只讲「什么时候该用它」。 + * 有关闭按钮,dismiss 状态存 localStorage,对老用户零打扰。 + */ +export default function ScenarioTips() { + const { t, i18n } = useTranslation('alertSelfHealing'); + const { darkMode } = useContext(CommonStateContext); + const [visible, setVisible] = useState(() => localStorage.getItem(STORAGE_KEY) !== 'true'); + + if (!visible) return null; + + return ( + + ); +} diff --git a/src/pages/taskTpl/components/TemplateLibrary/index.tsx b/src/pages/taskTpl/components/TemplateLibrary/index.tsx new file mode 100644 index 000000000..57a9eda7e --- /dev/null +++ b/src/pages/taskTpl/components/TemplateLibrary/index.tsx @@ -0,0 +1,72 @@ +import React from 'react'; +import { Drawer, Button, Tag } from 'antd'; +import { useHistory } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; +import _ from 'lodash'; + +import { SCRIPT_TEMPLATES, ScriptTemplate, TemplateCategory, TemplateRisk } from '../../constants'; + +const NS = 'alertSelfHealing'; + +interface Props { + visible: boolean; + onClose: () => void; + /** 当前业务组,用于跳转新建页时携带 gid */ + gid?: string; +} + +const riskColorMap: Record = { + none: 'green', + medium: 'gold', + high: 'orange', + critical: 'red', +}; + +const CATEGORY_ORDER: TemplateCategory[] = ['diagnostic', 'remediation']; + +export default function TemplateLibrary({ visible, onClose, gid }: Props) { + const { t } = useTranslation(NS); + const history = useHistory(); + + const handleUse = (tpl: ScriptTemplate) => { + const search = gid ? `gid=${gid}&tplkey=${tpl.key}` : `tplkey=${tpl.key}`; + history.push({ pathname: '/job-tpls/add', search }); + onClose(); + }; + + const grouped = _.groupBy(SCRIPT_TEMPLATES, 'category'); + + return ( + + {CATEGORY_ORDER.map((category) => { + const list = grouped[category] || []; + if (_.isEmpty(list)) return null; + return ( +
+
{t(`templates.group_${category}`)}
+
+ {list.map((tpl) => ( +
+
+ {t(`templates.${tpl.key}.title`)} + {t(`templates.risk_${tpl.risk}`)} +
+
{t(`templates.${tpl.key}.desc`)}
+
+ + {/* 依赖:模板有 templates..deps 时取之,否则显示「无」 */} + {t('templates.deps_label')}:{t(`templates.${tpl.key}.deps`, { defaultValue: '' }) || t('templates.deps_none')} + + +
+
+ ))} +
+
+ ); + })} +
+ ); +} diff --git a/src/pages/taskTpl/components/VarPanel/index.tsx b/src/pages/taskTpl/components/VarPanel/index.tsx new file mode 100644 index 000000000..70a2c24c0 --- /dev/null +++ b/src/pages/taskTpl/components/VarPanel/index.tsx @@ -0,0 +1,90 @@ +import React, { useContext, useState } from 'react'; +import { Button, Tooltip } from 'antd'; +import { DownOutlined, RightOutlined, PlusOutlined } from '@ant-design/icons'; +import { Braces } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; + +import { CommonStateContext } from '@/App'; +import DocumentDrawer from '@/components/DocumentDrawer'; + +import { ALERT_VARS, DOC_URL } from '../../constants'; + +const NS = 'alertSelfHealing'; +const STORAGE_KEY = 'task_tpl_var_panel_collapsed'; + +interface Props { + /** 在脚本编辑器光标处插入文本 */ + onInsert: (text: string) => void; + /** 在脚本顶部插入读取骨架(幂等) */ + onInsertSkeleton: () => void; +} + +/** + * 可用变量面板:把「告警上下文以 JSON 从 stdin 传入」这一隐性契约摆到脚本编辑器旁, + * 列出字段 + 示例值,点击插入到光标处,并提供一键插入读取骨架。 + */ +export default function VarPanel({ onInsert, onInsertSkeleton }: Props) { + const { t, i18n } = useTranslation(NS); + const { darkMode } = useContext(CommonStateContext); + const [collapsed, setCollapsed] = useState(() => localStorage.getItem(STORAGE_KEY) === 'true'); + + const toggle = () => { + const next = !collapsed; + setCollapsed(next); + localStorage.setItem(STORAGE_KEY, String(next)); + }; + + return ( +
+
+ + {t('var_panel.title')} + {collapsed ? : } +
+ {!collapsed && ( +
+
+ {ALERT_VARS.map((v) => ( +
+
+
+ {v.name} + {!v.always && {t('var_panel.optional_mark')}} +
+
+ {t(`var_panel.desc.${v.name}`)} + e.g. {v.example} +
+
+ + + +
+ ))} +
+
{t('var_panel.hint')}
+ +
+ )} +
+ ); +} diff --git a/src/pages/taskTpl/constants.ts b/src/pages/taskTpl/constants.ts new file mode 100644 index 000000000..dc8caa1a9 --- /dev/null +++ b/src/pages/taskTpl/constants.ts @@ -0,0 +1,67 @@ +// 自愈脚本模块的页面级常量:文档地址、告警变量清单、脚本骨架与内置模板库。 +// 脚本正文用 Vite 的 ?raw 以纯文本导入,避免在 JS 里对 shell 的 ${} 反复转义。 +import SCRIPT_SKELETON from './scriptTemplates/_skeleton.sh?raw'; +import t1 from './scriptTemplates/t1_snapshot.sh?raw'; +import t2 from './scriptTemplates/t2_disk_usage.sh?raw'; +import t3 from './scriptTemplates/t3_proc_port.sh?raw'; +import t4 from './scriptTemplates/t4_disk_clean.sh?raw'; +import t5 from './scriptTemplates/t5_service_restart.sh?raw'; +import t6 from './scriptTemplates/t6_log_truncate.sh?raw'; +import t7 from './scriptTemplates/t7_jvm_dump.sh?raw'; +import t8 from './scriptTemplates/t8_nginx_reload.sh?raw'; + +export { SCRIPT_SKELETON }; + +export const DOC_URL = 'https://flashcat.cloud/docs/content/flashcat-monitor/nightingale-v9/usage/alert-notify/self-healing/self-healing-script/'; + +// 脚本模板库暂未开放:入口先隐藏,后续支持时置为 true 即可(组件、模板、脚本均已就位) +export const SCRIPT_TEMPLATES_ENABLED = false; + +// 告警上下文变量:alert_* 三个固定注入字段一定有,事件标签(ident 等)因规则而异。 +// 说明文案走 i18n(var_panel.desc.)。 +export interface AlertVar { + /** 字段名 */ + name: string; + /** 面板里展示的示例值 */ + example: string; + /** 是否一定存在(固定注入字段 = true,事件标签 = false 需脚本兜底) */ + always: boolean; +} + +export const ALERT_VARS: AlertVar[] = [ + { name: 'alert_severity', example: '2', always: true }, + { name: 'alert_trigger_value', example: '93.5', always: true }, + { name: 'is_recovered', example: 'false', always: true }, + { name: 'ident', example: 'host-prod-01', always: false }, + { name: '__name__', example: 'cpu_usage_idle', always: false }, +]; + +export type TemplateCategory = 'diagnostic' | 'remediation'; +export type TemplateRisk = 'none' | 'medium' | 'high' | 'critical'; + +export interface ScriptTemplate { + /** 模板 key,同时是 i18n 前缀 templates..{title,desc,deps} */ + key: string; + category: TemplateCategory; + risk: TemplateRisk; + /** 完整脚本 = 骨架 + 正文,可直接跑 */ + script: string; + /** 默认参数(双逗号分隔),留空表示无 */ + args: string; + /** 默认单机超时(秒) */ + timeout: number; +} + +// 拼接:骨架已以「在下面写你的自愈逻辑」注释收尾,正文直接接在后面 +const compose = (body: string) => `${SCRIPT_SKELETON}\n${body}`; + +export const SCRIPT_TEMPLATES: ScriptTemplate[] = [ + { key: 'snapshot', category: 'diagnostic', risk: 'none', script: compose(t1), args: '', timeout: 60 }, + { key: 'disk_usage', category: 'diagnostic', risk: 'none', script: compose(t2), args: '/', timeout: 120 }, + { key: 'proc_port', category: 'diagnostic', risk: 'none', script: compose(t3), args: '', timeout: 30 }, + { key: 'disk_clean', category: 'remediation', risk: 'medium', script: compose(t4), args: '/var/log,,7,,1', timeout: 120 }, + { key: 'service_restart', category: 'remediation', risk: 'high', script: compose(t5), args: '', timeout: 60 }, + { key: 'log_truncate', category: 'remediation', risk: 'medium', script: compose(t6), args: '/var/log,,1024,,1', timeout: 60 }, + { key: 'jvm_dump', category: 'remediation', risk: 'critical', script: compose(t7), args: ',,/tmp', timeout: 300 }, + { key: 'nginx_reload', category: 'remediation', risk: 'medium', script: compose(t8), args: 'nginx', timeout: 30 }, +]; diff --git a/src/pages/taskTpl/editor.tsx b/src/pages/taskTpl/editor.tsx index 87c9b4254..cbc17c909 100644 --- a/src/pages/taskTpl/editor.tsx +++ b/src/pages/taskTpl/editor.tsx @@ -26,18 +26,21 @@ interface Props { readOnly: boolean; value?: string; onChange?: (value: string) => void; + /** 拿到底层 ace 实例,用于光标处插入变量 / 骨架 */ + onLoad?: (editor: any) => void; } export default function Editor(props: Props) { const { darkMode } = useContext(CommonStateContext); return ( void; + onOk: (hosts: any[], mode: 'replace' | 'append') => void; } const queryKeyOptions = ['all_hosts', 'tags', 'hosts']; function hostsFilterModal(props: Props & ModalWrapProps) { const { t } = useTranslation('alertRules'); + const { t: tsh } = useTranslation('alertSelfHealing'); const { visible, destroy, group_id, onOk } = props; const [form] = Form.useForm(); + const [mode, setMode] = useState<'replace' | 'append'>('replace'); return (
@@ -60,13 +62,21 @@ function hostsFilterModal(props: Props & ModalWrapProps) { limit: 100000, // TODO 临时解决方案,limit 100000 认为是获取全部 queries, }).then((res) => { - onOk(res?.dat?.list || []); + const list = res?.dat?.list || []; + message.success(tsh('filter_mode.matched', { count: list.length })); + onOk(list, mode); destroy(); }); }); }} onCancel={destroy} > +
+ setMode(e.target.value)} optionType='button' size='small'> + {tsh('filter_mode.replace')} + {tsh('filter_mode.append')} + +
{fields.map((field, idx) => (
diff --git a/src/pages/taskTpl/index.tsx b/src/pages/taskTpl/index.tsx index 39fac1599..6788d8416 100644 --- a/src/pages/taskTpl/index.tsx +++ b/src/pages/taskTpl/index.tsx @@ -30,6 +30,8 @@ import { RequestMethod } from '@/store/common'; import api from '@/utils/api'; import PageLayout from '@/components/pageLayout'; import BlankBusinessPlaceholder from '@/components/BlankBusinessPlaceholder'; +import EmptyGuide from '@/components/EmptyGuide'; +import DocumentDrawer from '@/components/DocumentDrawer'; import { CommonStateContext } from '@/App'; import BusinessGroupSideBarWithAll, { getDefaultGids } from '@/components/BusinessGroup/BusinessGroupSideBarWithAll'; import SearchInput from '@/components/BaseSearchInput'; @@ -38,6 +40,9 @@ import usePagination from '@/components/usePagination'; import { Tpl } from './interface'; import BindTags from './bindTags'; import UnBindTags from './unBindTags'; +import ScenarioList from './components/ScenarioList'; +import TemplateLibrary from './components/TemplateLibrary'; +import { DOC_URL, SCRIPT_TEMPLATES_ENABLED } from './constants'; const N9E_GIDS_LOCALKEY = 'N9E_TASK_TPL_NODE_ID'; const SEARCH_SESSION_KEY = 'taskTpl_query'; @@ -64,10 +69,13 @@ function getTableData(options: any, gids: string | undefined, query: string) { const index = (_props: any) => { const { t, i18n } = useTranslation('common'); + const { t: tsh } = useTranslation('alertSelfHealing'); const history = useHistory(); + const { darkMode } = useContext(CommonStateContext); const [query, setQuery] = useState(() => sessionStorage.getItem(SEARCH_SESSION_KEY) || ''); const { busiGroups, businessGroup } = useContext(CommonStateContext); const [selectedIds, setSelectedIds] = useState([] as any[]); + const [templateVisible, setTemplateVisible] = useState(false); const [gids, setGids] = useState(getDefaultGids(N9E_GIDS_LOCALKEY, businessGroup)); useEffect(() => { sessionStorage.setItem(SEARCH_SESSION_KEY, query); @@ -104,7 +112,9 @@ const index = (_props: any) => { function handleBatchUnBindTags() { if (!_.isEmpty(selectedIds)) { let uniqueTags = [] as any[]; - _.each(tableProps.dataSource, (item) => { + // 候选标签只取「被选中的行」的并集:只能解绑选中脚本上真实存在的标签 + const selectedRows = _.filter(tableProps.dataSource, (item: any) => _.includes(selectedIds, item.id)); + _.each(selectedRows, (item) => { const tags = item.tags; uniqueTags = _.union(uniqueTags, tags); }); @@ -144,7 +154,7 @@ const index = (_props: any) => { [ tagsColumn({ title: t('tpl.tags'), dataIndex: 'tags', maxWidth: 180, onTagClick: handleTagClick }), updateByColumn({ title: t('common:table.update_by'), dataIndex: 'update_by', nickname: 'update_by_nickname' }), - dateColumn({ title: t('common:table.update_at'), dataIndex: 'update_at', unix: true, sortable: true }), + dateColumn({ title: t('common:table.update_at'), dataIndex: 'update_at', unix: true }), ] as any, ); @@ -171,6 +181,11 @@ const index = (_props: any) => { {businessGroup.isLeaf && gids !== '-2' && ( + {SCRIPT_TEMPLATES_ENABLED && ( + + )} + {SCRIPT_TEMPLATES_ENABLED && } + DocumentDrawer({ language: i18n.language, darkMode, title: t('common:page_help'), type: 'iframe', documentPath: DOC_URL })}> + {tsh('empty_guide.doc')} + + + } + /> + ) : undefined, + }} rowSelection={{ selectedRowKeys: selectedIds, onChange: (selectedRowKeys) => { @@ -272,6 +314,7 @@ const index = (_props: any) => { )}
+ {SCRIPT_TEMPLATES_ENABLED && setTemplateVisible(false)} gid={gids} />} ); }; diff --git a/src/pages/taskTpl/interface.ts b/src/pages/taskTpl/interface.ts index e9e4d5d13..6565bfc37 100644 --- a/src/pages/taskTpl/interface.ts +++ b/src/pages/taskTpl/interface.ts @@ -21,7 +21,7 @@ export interface Tpl { batch: number; // 并发度,页面填充的默认值是0, tolerance: number; // 容忍度,页面填充的默认值是0, timeout: number; // 单机超时时间,页面填充的默认值是30, - pause: number; // 暂停点,默认值为空, + pause: string; // 暂停点,逗号分隔的主机列表,默认值为空, script: string; // 脚本内容,默认值参看之前四维的版本, args: string; // 脚本参数,默认值为空, tags: string; // 自愈脚本的标签, diff --git a/src/pages/taskTpl/locale/en_US.ts b/src/pages/taskTpl/locale/en_US.ts new file mode 100644 index 000000000..38f99f15c --- /dev/null +++ b/src/pages/taskTpl/locale/en_US.ts @@ -0,0 +1,144 @@ +const en_US = { + section: { + basic_title: 'Basic info', + basic_desc: 'Give the script a recognizable name for lists and alerts', + script_title: 'Script', + script_desc: 'The script to run; alert context is available via stdin — see the variables on the right', + target_title: 'Execution target', + target_desc: 'Which hosts to run on, and as which account', + strategy_title: 'Execution strategy', + strategy_desc: 'Concurrency, tolerance, timeout and other advanced options — defaults are usually fine', + }, + summary: { + unnamed: 'Unnamed', + no_tags: 'No tags', + tags_count: '{{count}} tag(s)', + script_lines: '{{count}} line(s)', + no_args: 'No args', + args_count: '{{count}} arg(s)', + hosts_count: '{{count}} host(s)', + no_hosts: 'No hosts', + concurrency_full: 'Full parallel', + concurrency_seq: 'Sequential', + concurrency_n: '{{count}} at a time', + tolerance_none: 'No failures tolerated', + tolerance_n: 'Tolerate {{count}} failure(s)', + timeout: 'Timeout {{count}}s', + }, + var_panel: { + title: 'Available variables', + insert: 'Insert', + insert_skeleton: 'Insert reader snippet', + doc: 'View docs', + optional_mark: 'may be absent', + hint: 'Alert context is passed as JSON via stdin; it is empty on manual runs, so guard with defaults.', + desc: { + alert_severity: 'Alert severity (1/2/3)', + alert_trigger_value: 'Alert trigger value', + is_recovered: 'Recovery event? (always false — healing only runs on trigger)', + ident: 'Host ident (host-type alerts only)', + __name__: 'Metric name (Prometheus-like sources)', + }, + }, + templates: { + entry_btn: 'From template', + drawer_title: 'Pick a script template', + group_diagnostic: 'Diagnostic (read-only, zero risk)', + group_remediation: 'Remediation (has side effects)', + use_btn: 'Use as template', + deps_label: 'Requires', + deps_none: 'None', + risk_none: 'No risk', + risk_medium: 'Medium risk', + risk_high: 'High risk', + risk_critical: 'Critical risk', + snapshot: { + title: 'Collect on-site snapshot', + desc: 'One-shot capture of load / CPU / memory / disk / ports / kernel logs — read-only, the first triage step', + }, + disk_usage: { + title: 'Locate disk usage', + desc: 'Find large dirs, large files and deleted-but-held file handles — read-only', + }, + proc_port: { + title: 'Process & port liveness check', + desc: 'Check whether a key process and listening port are alive — read-only', + }, + disk_clean: { + title: 'Clean disk space', + desc: 'Remove expired logs / archives with a protected-path blacklist; dry-run when the 3rd arg DRY_RUN=1', + }, + service_restart: { + title: 'Restart service', + desc: 'Restart a systemd service and verify recovery, keeping the pre-restart status and logs', + deps: 'systemd', + }, + log_truncate: { + title: 'Truncate large logs', + desc: 'Truncate oversized logs (keeps inode, no process restart needed); dry-run by default', + }, + jvm_dump: { + title: 'Preserve JVM crime scene', + desc: 'Capture jstack / jmap / heap dump; by default preserves the scene without auto-restart', + deps: 'JDK', + }, + nginx_reload: { + title: 'Validate & reload Nginx', + desc: 'Run nginx -t first, reload only if valid; abort on error (keep current config running)', + deps: 'nginx', + }, + }, + scenario_tips: { + title: 'When to use a self-healing script?', + more: 'Learn more', + disk: 'Disk filling up: auto-clean expired logs and archives so you are not woken at night', + service: 'Hung process / service: auto-restart and verify recovery, with pre-restart logs for later analysis', + oom: 'OOM / high load: auto-capture jstack, heap dump first, then decide whether to restart', + }, + empty_guide: { + title: 'No self-healing scripts yet', + desc_title: 'A self-healing script runs automatically on target hosts when an alert fires. Common cases:', + doc: 'View docs', + }, + prereq: { + title: 'Prerequisite', + desc: 'Target hosts need categraf deployed and ibex enable set to true, otherwise the task will not run. Check with: ps -ef | grep categraf', + }, + next_step: { + title: 'Self-healing script saved', + desc: 'Next: bind this script to a rule under the alert rule\'s Notify section, so it runs automatically when the alert fires.', + go_bind: 'Go to alert rules', + later: 'Later', + }, + account_root_warning: 'Running as root is highly privileged — make sure the script is safe', + task_empty: { + title: 'No execution records yet', + filtered_title: 'No records under the current filters', + filtered_desc: 'Defaults to mine-only and last 7 days. Relax the filters to see all.', + reset_btn: 'View all', + }, + source: { + title: 'Trigger source', + alert: 'Alert', + manual: 'Manual', + }, + result: { + status_bar: 'Overview', + retry_failed: 'Retry failed hosts', + retry_failed_confirm: 'Re-run on {{count}} failed / timed-out host(s). Continue?', + retry_partial_failed: '{{count}} host(s) failed to retry', + no_failed: 'No failed hosts', + search_output: 'Search output', + wrap: 'Wrap', + copy: 'Copy', + download: 'Download', + copied: 'Copied', + }, + host_count: '{{count}} host(s)', + filter_mode: { + replace: 'Replace', + append: 'Append', + matched: 'Matched {{count}}', + }, +}; +export default en_US; diff --git a/src/pages/taskTpl/locale/index.ts b/src/pages/taskTpl/locale/index.ts new file mode 100644 index 000000000..b704f3fa8 --- /dev/null +++ b/src/pages/taskTpl/locale/index.ts @@ -0,0 +1,17 @@ +import en_US from './en_US'; +import zh_CN from './zh_CN'; +import zh_HK from './zh_HK'; +import ja_JP from './ja_JP'; +import ru_RU from './ru_RU'; + +const resources = { + alertSelfHealing: { + en_US, + zh_CN, + zh_HK, + ja_JP, + ru_RU, + }, +}; + +export default resources; diff --git a/src/pages/taskTpl/locale/ja_JP.ts b/src/pages/taskTpl/locale/ja_JP.ts new file mode 100644 index 000000000..40cf04f69 --- /dev/null +++ b/src/pages/taskTpl/locale/ja_JP.ts @@ -0,0 +1,144 @@ +const ja_JP = { + section: { + basic_title: '基本情報', + basic_desc: 'リストやアラートで識別しやすい名前を付けます', + script_title: 'スクリプト', + script_desc: '実行するスクリプト。アラートコンテキストは stdin から取得でき、右側に利用可能な変数を表示しています', + target_title: '実行対象', + target_desc: 'どのマシンで、どのアカウントで実行するか', + strategy_title: '実行戦略', + strategy_desc: '並列度・許容度・タイムアウトなどの詳細設定。通常はデフォルトのままで問題ありません', + }, + summary: { + unnamed: '無名', + no_tags: 'タグなし', + tags_count: '{{count}} 個のタグ', + script_lines: '{{count}} 行', + no_args: '引数なし', + args_count: '{{count}} 個の引数', + hosts_count: '{{count}} 台のホスト', + no_hosts: 'ホスト未指定', + concurrency_full: '全並列', + concurrency_seq: '順次実行', + concurrency_n: '毎回 {{count}} 台', + tolerance_none: '失敗を許容しない', + tolerance_n: '{{count}} 台の失敗を許容', + timeout: 'タイムアウト {{count}}s', + }, + var_panel: { + title: '利用可能な変数', + insert: '挿入', + insert_skeleton: '読み取りスニペットを挿入', + doc: 'ドキュメント', + optional_mark: '存在しない場合あり', + hint: 'アラートコンテキストは JSON として stdin から渡されます。手動実行時は空なので、スクリプト側でデフォルト値を用意してください。', + desc: { + alert_severity: 'アラートレベル(1/2/3)', + alert_trigger_value: 'アラートのトリガー値', + is_recovered: '回復イベントか(自己修復はトリガー時のみ実行、常に false)', + ident: 'ホスト識別子(ホスト型アラートのみ)', + __name__: 'メトリクス名(Prometheus 系データソース)', + }, + }, + templates: { + entry_btn: 'テンプレートから作成', + drawer_title: 'スクリプトテンプレートを選択', + group_diagnostic: '診断系(読み取り専用・リスクなし)', + group_remediation: '修復系(副作用あり)', + use_btn: 'これをテンプレートにして作成', + deps_label: '依存', + deps_none: 'なし', + risk_none: 'リスクなし', + risk_medium: '中リスク', + risk_high: '高リスク', + risk_critical: '極高リスク', + snapshot: { + title: '現場スナップショット収集', + desc: '負荷 / CPU / メモリ / ディスク / ポート / カーネルログを一括収集。読み取り専用、トラブル対応の第一歩', + }, + disk_usage: { + title: 'ディスク使用状況の特定', + desc: '大きなディレクトリ・ファイル・削除済みだが保持中のハンドルを特定。読み取り専用', + }, + proc_port: { + title: 'プロセスとポートの死活監視', + desc: '重要なプロセスと待受ポートが生きているか確認。読み取り専用', + }, + disk_clean: { + title: 'ディスク空き容量の清掃', + desc: '期限切れログ / アーカイブを削除。保護パスのブラックリスト内蔵、第3引数 DRY_RUN=1 でドライラン', + }, + service_restart: { + title: 'サービス再起動', + desc: 'systemd サービスを再起動して回復を検証、再起動前の状態とログを保持', + deps: 'systemd', + }, + log_truncate: { + title: '大きなログの切り詰め', + desc: 'truncate で巨大ログを整理(inode を保持、プロセス再起動不要)。デフォルトでドライラン', + }, + jvm_dump: { + title: 'JVM 現場保全', + desc: 'jstack / jmap / ヒープダンプを取得。デフォルトでは現場を保全し自動再起動しない', + deps: 'JDK', + }, + nginx_reload: { + title: 'Nginx 設定検証と reload', + desc: '先に nginx -t で検証してから reload、エラー時は中止(現行設定を維持)', + deps: 'nginx', + }, + }, + scenario_tips: { + title: '自己修復スクリプトはいつ使う?', + more: '詳しく見る', + disk: 'ディスクが満杯に:期限切れログ・アーカイブを自動清掃し、深夜の呼び出しを回避', + service: 'プロセス / サービスのハング:自動再起動して回復を検証、再起動前ログで事後分析', + oom: 'OOM / 高負荷:まず jstack、ヒープダンプなどの現場を自動取得してから再起動を判断', + }, + empty_guide: { + title: 'まだ自己修復スクリプトがありません', + desc_title: '自己修復スクリプトはアラート発生時に対象マシンで自動実行されます。よくある場面:', + doc: 'ドキュメント', + }, + prereq: { + title: '前提条件', + desc: '対象マシンに categraf をデプロイし、ibex の enable を true に設定する必要があります。そうでないとタスクは実行されません。ps -ef | grep categraf で確認できます。', + }, + next_step: { + title: '自己修復スクリプトを保存しました', + desc: '次のステップ:アラートルールの「通知設定」でこのスクリプトをルールに紐付けると、アラート発生時に自動実行されます。', + go_bind: 'アラートルールへ', + later: '後で', + }, + account_root_warning: 'root での実行は権限が大きいため、スクリプトの安全性を確認してください', + task_empty: { + title: 'まだ実行記録がありません', + filtered_title: '現在のフィルタでは記録がありません', + filtered_desc: 'デフォルトは自分のみ・直近7日です。フィルタを緩めて全件を表示できます。', + reset_btn: 'すべて表示', + }, + source: { + title: 'トリガー元', + alert: 'アラート', + manual: '手動作成', + }, + result: { + status_bar: '実行概況', + retry_failed: '失敗ホストを再試行', + retry_failed_confirm: '{{count}} 台の失敗 / タイムアウトしたホストを再実行します。続行しますか?', + retry_partial_failed: '{{count}} 台のホストの再試行に失敗しました', + no_failed: '失敗したホストはありません', + search_output: '出力を検索', + wrap: '折り返し', + copy: 'コピー', + download: 'ダウンロード', + copied: 'コピーしました', + }, + host_count: '合計 {{count}} 台', + filter_mode: { + replace: '置換', + append: '追加', + matched: '{{count}} 台に一致', + }, +}; +export default ja_JP; diff --git a/src/pages/taskTpl/locale/ru_RU.ts b/src/pages/taskTpl/locale/ru_RU.ts new file mode 100644 index 000000000..93e7885f1 --- /dev/null +++ b/src/pages/taskTpl/locale/ru_RU.ts @@ -0,0 +1,144 @@ +const ru_RU = { + section: { + basic_title: 'Основное', + basic_desc: 'Дайте скрипту понятное имя для списков и оповещений', + script_title: 'Скрипт', + script_desc: 'Скрипт для запуска; контекст оповещения доступен через stdin — переменные показаны справа', + target_title: 'Цель выполнения', + target_desc: 'На каких хостах и под какой учётной записью выполнять', + strategy_title: 'Стратегия выполнения', + strategy_desc: 'Параллелизм, допуск ошибок, таймаут и другие расширенные параметры — обычно подходят значения по умолчанию', + }, + summary: { + unnamed: 'Без имени', + no_tags: 'Без тегов', + tags_count: 'тегов: {{count}}', + script_lines: 'строк: {{count}}', + no_args: 'Без аргументов', + args_count: 'аргументов: {{count}}', + hosts_count: 'хостов: {{count}}', + no_hosts: 'Хосты не указаны', + concurrency_full: 'Полный параллелизм', + concurrency_seq: 'Последовательно', + concurrency_n: 'по {{count}} за раз', + tolerance_none: 'Без допуска сбоев', + tolerance_n: 'Допуск сбоев: {{count}}', + timeout: 'Таймаут {{count}}с', + }, + var_panel: { + title: 'Доступные переменные', + insert: 'Вставить', + insert_skeleton: 'Вставить шаблон чтения', + doc: 'Документация', + optional_mark: 'может отсутствовать', + hint: 'Контекст оповещения передаётся как JSON через stdin; при ручном запуске он пуст, поэтому используйте значения по умолчанию.', + desc: { + alert_severity: 'Уровень оповещения (1/2/3)', + alert_trigger_value: 'Значение срабатывания', + is_recovered: 'Событие восстановления? (всегда false — самовосстановление только при срабатывании)', + ident: 'Идентификатор хоста (только для хостовых оповещений)', + __name__: 'Имя метрики (источники типа Prometheus)', + }, + }, + templates: { + entry_btn: 'Из шаблона', + drawer_title: 'Выберите шаблон скрипта', + group_diagnostic: 'Диагностика (только чтение, без риска)', + group_remediation: 'Устранение (с побочными эффектами)', + use_btn: 'Использовать как шаблон', + deps_label: 'Требует', + deps_none: 'Нет', + risk_none: 'Без риска', + risk_medium: 'Средний риск', + risk_high: 'Высокий риск', + risk_critical: 'Критический риск', + snapshot: { + title: 'Сбор снимка состояния', + desc: 'Разовый сбор нагрузки / CPU / памяти / диска / портов / логов ядра — только чтение, первый шаг диагностики', + }, + disk_usage: { + title: 'Поиск использования диска', + desc: 'Найти большие каталоги, файлы и удалённые, но удерживаемые дескрипторы — только чтение', + }, + proc_port: { + title: 'Проверка процесса и порта', + desc: 'Проверить, живы ли ключевой процесс и слушающий порт — только чтение', + }, + disk_clean: { + title: 'Очистка места на диске', + desc: 'Удаление устаревших логов / архивов с чёрным списком защищённых путей; пробный запуск при 3-м аргументе DRY_RUN=1', + }, + service_restart: { + title: 'Перезапуск сервиса', + desc: 'Перезапуск сервиса systemd с проверкой восстановления, сохраняя состояние и логи до перезапуска', + deps: 'systemd', + }, + log_truncate: { + title: 'Усечение больших логов', + desc: 'Усечение огромных логов (сохраняет inode, перезапуск процесса не нужен); по умолчанию пробный запуск', + }, + jvm_dump: { + title: 'Сохранение состояния JVM', + desc: 'Снятие jstack / jmap / дампа кучи; по умолчанию сохраняет состояние без авто-перезапуска', + deps: 'JDK', + }, + nginx_reload: { + title: 'Проверка и reload Nginx', + desc: 'Сначала nginx -t, reload только если валидно; отмена при ошибке (текущая конфигурация продолжает работать)', + deps: 'nginx', + }, + }, + scenario_tips: { + title: 'Когда использовать скрипт самовосстановления?', + more: 'Подробнее', + disk: 'Диск заполняется: авто-очистка устаревших логов и архивов, чтобы не будили ночью', + service: 'Зависший процесс / сервис: авто-перезапуск с проверкой восстановления и логами до перезапуска', + oom: 'OOM / высокая нагрузка: сначала авто-снятие jstack, дампа кучи, затем решение о перезапуске', + }, + empty_guide: { + title: 'Пока нет скриптов самовосстановления', + desc_title: 'Скрипт самовосстановления автоматически выполняется на целевых хостах при срабатывании оповещения. Типичные случаи:', + doc: 'Документация', + }, + prereq: { + title: 'Предварительное требование', + desc: 'На целевых хостах должен быть развёрнут categraf, а в ibex параметр enable = true, иначе задача не выполнится. Проверьте: ps -ef | grep categraf', + }, + next_step: { + title: 'Скрипт самовосстановления сохранён', + desc: 'Далее: привяжите этот скрипт к правилу в разделе «Уведомления» правила оповещения, чтобы он запускался автоматически при срабатывании.', + go_bind: 'К правилам оповещений', + later: 'Позже', + }, + account_root_warning: 'Запуск от root даёт большие права — убедитесь, что скрипт безопасен', + task_empty: { + title: 'Пока нет записей о выполнении', + filtered_title: 'Нет записей по текущим фильтрам', + filtered_desc: 'По умолчанию только свои и последние 7 дней. Ослабьте фильтры, чтобы увидеть все.', + reset_btn: 'Показать все', + }, + source: { + title: 'Источник', + alert: 'Оповещение', + manual: 'Вручную', + }, + result: { + status_bar: 'Обзор', + retry_failed: 'Повторить неуспешные', + retry_failed_confirm: 'Повторный запуск на {{count}} неуспешных / истёкших хостах. Продолжить?', + retry_partial_failed: 'Не удалось повторить на {{count}} хостах', + no_failed: 'Нет неуспешных хостов', + search_output: 'Поиск в выводе', + wrap: 'Перенос', + copy: 'Копировать', + download: 'Скачать', + copied: 'Скопировано', + }, + host_count: 'Всего: {{count}}', + filter_mode: { + replace: 'Заменить', + append: 'Добавить', + matched: 'Совпало: {{count}}', + }, +}; +export default ru_RU; diff --git a/src/pages/taskTpl/locale/zh_CN.ts b/src/pages/taskTpl/locale/zh_CN.ts new file mode 100644 index 000000000..93079ea26 --- /dev/null +++ b/src/pages/taskTpl/locale/zh_CN.ts @@ -0,0 +1,144 @@ +const zh_CN = { + section: { + basic_title: '基本信息', + basic_desc: '给脚本起个能看懂的名字,方便在列表和告警里识别', + script_title: '脚本内容', + script_desc: '要执行的脚本;告警上下文可从 stdin 读取,右侧列出了可用变量', + target_title: '执行目标', + target_desc: '在哪些机器、以什么账号执行', + strategy_title: '执行策略', + strategy_desc: '并发、容错、超时等高级项,通常保持默认即可', + }, + summary: { + unnamed: '未命名', + no_tags: '无标签', + tags_count: '{{count}} 个标签', + script_lines: '{{count}} 行脚本', + no_args: '无参数', + args_count: '{{count}} 个参数', + hosts_count: '{{count}} 台主机', + no_hosts: '未指定主机', + concurrency_full: '全并发', + concurrency_seq: '顺序执行', + concurrency_n: '每次 {{count}} 台', + tolerance_none: '不容忍失败', + tolerance_n: '容忍 {{count}} 台失败', + timeout: '超时 {{count}}s', + }, + var_panel: { + title: '可用变量', + insert: '插入', + insert_skeleton: '插入读取骨架', + doc: '查看文档', + optional_mark: '非必有', + hint: '告警上下文以 JSON 从 stdin 传入;手动执行时为空,脚本需自行兜底。', + desc: { + alert_severity: '告警级别(1/2/3)', + alert_trigger_value: '告警触发值', + is_recovered: '是否恢复事件(自愈仅在触发时执行,恒为 false)', + ident: '主机标识(主机类告警才有)', + __name__: '指标名(Prometheus 类数据源)', + }, + }, + templates: { + entry_btn: '从模板创建', + drawer_title: '选择脚本模板', + group_diagnostic: '诊断类(只读、零风险)', + group_remediation: '修复类(有副作用)', + use_btn: '以此为模板创建', + deps_label: '依赖', + deps_none: '无', + risk_none: '无风险', + risk_medium: '中风险', + risk_high: '高风险', + risk_critical: '极高风险', + snapshot: { + title: '现场快照采集', + desc: '一键采集负载 / CPU / 内存 / 磁盘 / 端口 / 内核日志,只读零风险,排障第一步', + }, + disk_usage: { + title: '磁盘占用定位', + desc: '定位大目录、大文件与「已删除但仍被占用」的句柄,只读', + }, + proc_port: { + title: '进程与端口存活检查', + desc: '检查关键进程和监听端口是否存活,只读', + }, + disk_clean: { + title: '磁盘空间清理', + desc: '清理过期日志 / 压缩包,内置受保护路径黑名单,第三个参数 DRY_RUN=1 时仅演练', + }, + service_restart: { + title: '服务重启', + desc: '重启 systemd 服务并校验恢复,保留重启前状态与日志', + deps: 'systemd', + }, + log_truncate: { + title: '大日志截断', + desc: '用 truncate 语义清理超大日志(保留 inode,进程无需重启),默认演练', + }, + jvm_dump: { + title: 'JVM 现场保留', + desc: '抓取 jstack / jmap / 堆 dump,默认只保留现场、不自动重启', + deps: 'JDK', + }, + nginx_reload: { + title: 'Nginx 配置校验并 reload', + desc: '先 nginx -t 校验配置再 reload,校验失败则中止(保持当前配置运行)', + deps: 'nginx', + }, + }, + scenario_tips: { + title: '什么时候用自愈脚本?', + more: '了解更多', + disk: '磁盘将满:自动清理过期日志、压缩包,避免半夜被叫醒', + service: '进程 / 服务假死:自动重启并校验恢复,附带重启前日志便于事后定位', + oom: 'OOM / 高负载:先自动抓取 jstack、堆 dump 等现场,再决定是否重启', + }, + empty_guide: { + title: '还没有自愈脚本', + desc_title: '自愈脚本能在告警触发时,自动在目标机器上执行,常见场景:', + doc: '查看文档', + }, + prereq: { + title: '前置依赖', + desc: '目标机器需部署 categraf,并将 ibex 配置 enable 设为 true,否则任务不会执行。可在机器上执行 ps -ef | grep categraf 自检。', + }, + next_step: { + title: '自愈脚本已保存', + desc: '下一步:到告警规则的「通知配置」里把这个脚本绑定到规则,告警触发时才会自动执行。', + go_bind: '去告警规则', + later: '稍后再说', + }, + account_root_warning: '使用 root 执行权限较大,请确认脚本内容安全', + task_empty: { + title: '还没有执行记录', + filtered_title: '当前筛选条件下没有记录', + filtered_desc: '默认只看自己、近 7 天,可放宽筛选查看全部。', + reset_btn: '查看全部', + }, + source: { + title: '触发来源', + alert: '告警触发', + manual: '手动创建', + }, + result: { + status_bar: '执行概况', + retry_failed: '重试失败主机', + retry_failed_confirm: '将对 {{count}} 台失败 / 超时的主机重新执行,确认继续?', + retry_partial_failed: '{{count}} 台主机重试请求失败', + no_failed: '没有失败的主机', + search_output: '搜索输出', + wrap: '自动换行', + copy: '复制', + download: '下载', + copied: '已复制', + }, + host_count: '共 {{count}} 台', + filter_mode: { + replace: '替换', + append: '追加', + matched: '匹配 {{count}} 台', + }, +}; +export default zh_CN; diff --git a/src/pages/taskTpl/locale/zh_HK.ts b/src/pages/taskTpl/locale/zh_HK.ts new file mode 100644 index 000000000..7223d4db2 --- /dev/null +++ b/src/pages/taskTpl/locale/zh_HK.ts @@ -0,0 +1,144 @@ +const zh_HK = { + section: { + basic_title: '基本資訊', + basic_desc: '給腳本起個能看懂的名字,方便在清單和告警裡識別', + script_title: '腳本內容', + script_desc: '要執行的腳本;告警上下文可從 stdin 讀取,右側列出了可用變數', + target_title: '執行目標', + target_desc: '在哪些機器、以什麼帳號執行', + strategy_title: '執行策略', + strategy_desc: '並發、容錯、逾時等進階項,通常保持預設即可', + }, + summary: { + unnamed: '未命名', + no_tags: '無標籤', + tags_count: '{{count}} 個標籤', + script_lines: '{{count}} 行腳本', + no_args: '無參數', + args_count: '{{count}} 個參數', + hosts_count: '{{count}} 台主機', + no_hosts: '未指定主機', + concurrency_full: '全並發', + concurrency_seq: '順序執行', + concurrency_n: '每次 {{count}} 台', + tolerance_none: '不容忍失敗', + tolerance_n: '容忍 {{count}} 台失敗', + timeout: '逾時 {{count}}s', + }, + var_panel: { + title: '可用變數', + insert: '插入', + insert_skeleton: '插入讀取骨架', + doc: '查看文件', + optional_mark: '非必有', + hint: '告警上下文以 JSON 從 stdin 傳入;手動執行時為空,腳本需自行兜底。', + desc: { + alert_severity: '告警級別(1/2/3)', + alert_trigger_value: '告警觸發值', + is_recovered: '是否恢復事件(自愈僅在觸發時執行,恆為 false)', + ident: '主機識別(主機類告警才有)', + __name__: '指標名(Prometheus 類資料來源)', + }, + }, + templates: { + entry_btn: '從模板建立', + drawer_title: '選擇腳本模板', + group_diagnostic: '診斷類(唯讀、零風險)', + group_remediation: '修復類(有副作用)', + use_btn: '以此為模板建立', + deps_label: '依賴', + deps_none: '無', + risk_none: '無風險', + risk_medium: '中風險', + risk_high: '高風險', + risk_critical: '極高風險', + snapshot: { + title: '現場快照採集', + desc: '一鍵採集負載 / CPU / 記憶體 / 磁碟 / 埠 / 核心日誌,唯讀零風險,排障第一步', + }, + disk_usage: { + title: '磁碟佔用定位', + desc: '定位大目錄、大檔案與「已刪除但仍被佔用」的控制代碼,唯讀', + }, + proc_port: { + title: '行程與埠存活檢查', + desc: '檢查關鍵行程和監聽埠是否存活,唯讀', + }, + disk_clean: { + title: '磁碟空間清理', + desc: '清理過期日誌 / 壓縮包,內建受保護路徑黑名單,第三個參數 DRY_RUN=1 時僅演練', + }, + service_restart: { + title: '服務重啟', + desc: '重啟 systemd 服務並校驗恢復,保留重啟前狀態與日誌', + deps: 'systemd', + }, + log_truncate: { + title: '大日誌截斷', + desc: '用 truncate 語義清理超大日誌(保留 inode,行程無需重啟),預設演練', + }, + jvm_dump: { + title: 'JVM 現場保留', + desc: '抓取 jstack / jmap / 堆 dump,預設只保留現場、不自動重啟', + deps: 'JDK', + }, + nginx_reload: { + title: 'Nginx 設定校驗並 reload', + desc: '先 nginx -t 校驗設定再 reload,校驗失敗則中止(保持目前設定執行)', + deps: 'nginx', + }, + }, + scenario_tips: { + title: '什麼時候用自愈腳本?', + more: '瞭解更多', + disk: '磁碟將滿:自動清理過期日誌、壓縮包,避免半夜被叫醒', + service: '行程 / 服務假死:自動重啟並校驗恢復,附帶重啟前日誌便於事後定位', + oom: 'OOM / 高負載:先自動抓取 jstack、堆 dump 等現場,再決定是否重啟', + }, + empty_guide: { + title: '還沒有自愈腳本', + desc_title: '自愈腳本能在告警觸發時,自動在目標機器上執行,常見場景:', + doc: '查看文件', + }, + prereq: { + title: '前置依賴', + desc: '目標機器需部署 categraf,並將 ibex 設定 enable 設為 true,否則任務不會執行。可在機器上執行 ps -ef | grep categraf 自檢。', + }, + next_step: { + title: '自愈腳本已儲存', + desc: '下一步:到告警規則的「通知設定」裡把這個腳本綁定到規則,告警觸發時才會自動執行。', + go_bind: '去告警規則', + later: '稍後再說', + }, + account_root_warning: '使用 root 執行權限較大,請確認腳本內容安全', + task_empty: { + title: '還沒有執行記錄', + filtered_title: '目前篩選條件下沒有記錄', + filtered_desc: '預設只看自己、近 7 天,可放寬篩選查看全部。', + reset_btn: '查看全部', + }, + source: { + title: '觸發來源', + alert: '告警觸發', + manual: '手動建立', + }, + result: { + status_bar: '執行概況', + retry_failed: '重試失敗主機', + retry_failed_confirm: '將對 {{count}} 台失敗 / 逾時的主機重新執行,確認繼續?', + retry_partial_failed: '{{count}} 台主機重試請求失敗', + no_failed: '沒有失敗的主機', + search_output: '搜尋輸出', + wrap: '自動換行', + copy: '複製', + download: '下載', + copied: '已複製', + }, + host_count: '共 {{count}} 台', + filter_mode: { + replace: '替換', + append: '追加', + matched: '匹配 {{count}} 台', + }, +}; +export default zh_HK; diff --git a/src/pages/taskTpl/modify.tsx b/src/pages/taskTpl/modify.tsx index 6d2b07002..0d201363d 100644 --- a/src/pages/taskTpl/modify.tsx +++ b/src/pages/taskTpl/modify.tsx @@ -39,7 +39,6 @@ const Modify = (props: any) => { const [loading, setLoading] = useState(false); const [data, setData] = useState({}); const handleSubmit = (values: any) => { - values.pause = _.join(values.pause, ','); request(`${api.tasktpl(curBusiId)}/${id}`, { method: 'PUT', body: JSON.stringify(values), diff --git a/src/pages/taskTpl/scriptTemplates/_skeleton.sh b/src/pages/taskTpl/scriptTemplates/_skeleton.sh new file mode 100644 index 000000000..4995f5f83 --- /dev/null +++ b/src/pages/taskTpl/scriptTemplates/_skeleton.sh @@ -0,0 +1,28 @@ +#!/bin/bash +# 告警触发时,事件信息以 JSON 从 stdin 传入(手动执行时为空) +# 固定字段:alert_severity / alert_trigger_value / is_recovered +# 其余为事件标签:ident / __name__ / instance ...(因规则而异,注意 evt 兜底) +set -uo pipefail +export PATH=/usr/local/bin:/bin:/usr/bin:/usr/local/sbin:/usr/sbin:/sbin:$PATH + +EVENT_JSON="$(cat 2>/dev/null || true)" +[ -z "$EVENT_JSON" ] && EVENT_JSON='{}' + +# evt <字段名> [默认值]:从告警上下文取值 +evt() { + local key="$1" def="${2:-}" val="" + if command -v jq >/dev/null 2>&1; then + # 后端 stdin 由 map[string]string 序列化,字段值均为带引号的字符串(含 alert_severity/is_recovered); + # 用 has + != null 而非 // empty,避免万一将来出现布尔 false / 数值 0 时被误判为缺省 + val="$(printf '%s' "$EVENT_JSON" | jq -r --arg k "$key" 'if has($k) and .[$k] != null then .[$k] else empty end' 2>/dev/null)" + else + val="$(printf '%s' "$EVENT_JSON" | sed -n "s/.*\"${key}\"[[:space:]]*:[[:space:]]*\"\([^\"]*\)\".*/\1/p" | head -n1)" + fi + [ -n "$val" ] && printf '%s' "$val" || printf '%s' "$def" +} + +IDENT="$(evt ident "$(hostname)")" +SEVERITY="$(evt alert_severity 0)" +echo "[自愈] 主机=${IDENT} 级别=S${SEVERITY}" + +# ====== 在下面写你的自愈逻辑 ====== diff --git a/src/pages/taskTpl/scriptTemplates/t1_snapshot.sh b/src/pages/taskTpl/scriptTemplates/t1_snapshot.sh new file mode 100644 index 000000000..8a8593074 --- /dev/null +++ b/src/pages/taskTpl/scriptTemplates/t1_snapshot.sh @@ -0,0 +1,28 @@ +echo "===== 1. 系统负载 =====" +uptime +echo +echo "===== 2. CPU Top 10 =====" +ps -eo pid,ppid,user,pcpu,pmem,etime,cmd --sort=-pcpu 2>/dev/null | head -n 11 +echo +echo "===== 3. 内存 Top 10 =====" +ps -eo pid,ppid,user,pcpu,pmem,etime,cmd --sort=-pmem 2>/dev/null | head -n 11 +echo +echo "===== 4. 内存总览 =====" +free -h 2>/dev/null || vm_stat +echo +echo "===== 5. 磁盘使用 =====" +df -hT 2>/dev/null | grep -Ev '^(tmpfs|devtmpfs|overlay)' +echo +echo "===== 6. inode 使用 =====" +df -i 2>/dev/null | grep -Ev '^(tmpfs|devtmpfs|overlay)' +echo +echo "===== 7. 监听端口 =====" +ss -tlnp 2>/dev/null || netstat -tlnp 2>/dev/null +echo +echo "===== 8. TCP 连接状态统计 =====" +ss -ant 2>/dev/null | awk 'NR>1{s[$1]++} END{for (k in s) print k, s[k]}' +echo +echo "===== 9. 最近内核消息(OOM / 硬件错误)=====" +dmesg -T 2>/dev/null | tail -n 30 || dmesg 2>/dev/null | tail -n 30 +echo +echo "[自愈] 快照采集完成" diff --git a/src/pages/taskTpl/scriptTemplates/t2_disk_usage.sh b/src/pages/taskTpl/scriptTemplates/t2_disk_usage.sh new file mode 100644 index 000000000..d7f0bfaf8 --- /dev/null +++ b/src/pages/taskTpl/scriptTemplates/t2_disk_usage.sh @@ -0,0 +1,27 @@ +SCAN_PATH="${1:-/}" + +echo "[自愈] 扫描路径:${SCAN_PATH}" +if [ ! -d "$SCAN_PATH" ]; then + echo "[自愈] 目录不存在:${SCAN_PATH}" >&2 + exit 1 +fi + +echo "===== 分区使用率 =====" +df -hT "$SCAN_PATH" 2>/dev/null + +echo +echo "===== 一级目录占用 TOP 20 =====" +du -xh --max-depth=1 "$SCAN_PATH" 2>/dev/null | sort -rh | head -n 20 + +echo +echo "===== 大文件 TOP 20(>100MB)=====" +find "$SCAN_PATH" -xdev -type f -size +100M -printf '%s\t%p\n' 2>/dev/null \ + | sort -rn | head -n 20 \ + | awk '{printf "%.1f GB\t%s\n", $1/1024/1024/1024, $2}' + +echo +echo "===== 已删除但仍被占用的文件(句柄泄漏)=====" +lsof -nP 2>/dev/null | awk '/deleted/ {sum[$1"/"$2]+=$7} END{for (k in sum) printf "%.1f MB\t%s\n", sum[k]/1024/1024, k}' \ + | sort -rn | head -n 10 + +echo "[自愈] 定位完成。若「已删除但被占用」占比很高,需重启对应进程而非删文件。" diff --git a/src/pages/taskTpl/scriptTemplates/t3_proc_port.sh b/src/pages/taskTpl/scriptTemplates/t3_proc_port.sh new file mode 100644 index 000000000..915fe6eaf --- /dev/null +++ b/src/pages/taskTpl/scriptTemplates/t3_proc_port.sh @@ -0,0 +1,33 @@ +PROC_KEY="${1:-}" +PORT="${2:-}" + +RC=0 + +if [ -n "$PROC_KEY" ]; then + echo "===== 进程检查:${PROC_KEY} =====" + if pgrep -af "$PROC_KEY" 2>/dev/null; then + echo "[自愈] 进程存活,实例数:$(pgrep -cf "$PROC_KEY")" + else + echo "[自愈] ✗ 未找到进程:${PROC_KEY}" >&2 + RC=1 + fi + echo +fi + +if [ -n "$PORT" ]; then + echo "===== 端口检查:${PORT} =====" + if ss -tlnp 2>/dev/null | grep -q ":${PORT}\b" || netstat -tlnp 2>/dev/null | grep -q ":${PORT}\b"; then + ss -tlnp 2>/dev/null | grep ":${PORT}\b" || netstat -tlnp 2>/dev/null | grep ":${PORT}\b" + echo "[自愈] 端口监听正常" + else + echo "[自愈] ✗ 端口未监听:${PORT}" >&2 + RC=1 + fi +fi + +if [ -z "$PROC_KEY" ] && [ -z "$PORT" ]; then + echo "[自愈] 未传入参数。用法:进程关键字,,端口" >&2 + exit 2 +fi + +exit $RC diff --git a/src/pages/taskTpl/scriptTemplates/t4_disk_clean.sh b/src/pages/taskTpl/scriptTemplates/t4_disk_clean.sh new file mode 100644 index 000000000..3492c5140 --- /dev/null +++ b/src/pages/taskTpl/scriptTemplates/t4_disk_clean.sh @@ -0,0 +1,63 @@ +TARGET_DIR="${1:-/var/log}" +KEEP_DAYS="${2:-7}" +DRY_RUN="${3:-1}" + +echo "[自愈] 目录=${TARGET_DIR} 保留=${KEEP_DAYS}天 DRY_RUN=${DRY_RUN}" + +# 规范化路径:cd -P 解开符号链接与 ../、// 等等价写法,避免绕过下面的黑名单 +REAL_DIR="$(cd -P -- "$TARGET_DIR" 2>/dev/null && pwd -P)" || { + echo "[自愈] ✗ 目录无效:${TARGET_DIR}" >&2 + exit 1 +} + +# 安全闸:拒绝在受保护路径(及其子树)上执行 +case "$REAL_DIR" in + /|/etc|/etc/*|/bin|/bin/*|/sbin|/sbin/*|/usr|/usr/*|/lib|/lib/*|/lib64|/lib64/*|/boot|/boot/*|/root|/root/*|/home|/home/*) + echo "[自愈] ✗ 拒绝清理受保护路径:${REAL_DIR}" >&2 + exit 1 + ;; +esac + +# 兜底:规范化后不足两层目录一律拒绝(如 / 、/data) +DEPTH="$(printf '%s' "${REAL_DIR#/}" | awk -F/ '{c=0; for (i=1;i<=NF;i++) if ($i!="") c++; print c}')" +if [ "${DEPTH:-0}" -lt 2 ]; then + echo "[自愈] ✗ 目录层级过浅,拒绝执行:${REAL_DIR}" >&2 + exit 1 +fi + +BEFORE="$(df -Pk "$REAL_DIR" 2>/dev/null | awk 'NR==2{print $4}')" +echo "[自愈] 清理前可用:$((BEFORE/1024)) MB" + +# 用 NUL 分隔收集结果,预览 / 计数 / 删除都基于同一份列表,避免含空格 / 引号的文件名被错分词 +TMPLIST="$(mktemp)" || exit 1 +trap 'rm -f "$TMPLIST"' EXIT +find "$REAL_DIR" -xdev -type f \ + \( -name '*.log.*' -o -name '*.gz' -o -name '*.zip' -o -name '*.old' -o -name '*.bak' \) \ + -mtime "+${KEEP_DAYS}" -print0 > "$TMPLIST" 2>/dev/null + +if [ ! -s "$TMPLIST" ]; then + echo "[自愈] 没有符合条件的文件,无需清理" + exit 0 +fi + +echo "===== 待清理文件(*.log.*/*.gz/*.zip/*.old/*.bak,mtime > ${KEEP_DAYS}天)=====" +tr '\0' '\n' < "$TMPLIST" | head -n 50 +COUNT="$(tr -dc '\0' < "$TMPLIST" | wc -c | tr -d ' ')" +echo "[自愈] 共匹配 ${COUNT} 个文件" + +# DRY_RUN 白名单 fail-closed:只有明确的 0 才真正删除,非法值直接退出 +case "$DRY_RUN" in + 1) + echo "[自愈] DRY_RUN=1,仅预览未删除。确认无误后把第三个参数改为 0 再执行。" + exit 0 + ;; + 0) ;; + *) + echo "[自愈] ✗ DRY_RUN 只接受 0 或 1,当前=${DRY_RUN}" >&2 + exit 2 + ;; +esac + +xargs -0 -r rm -f -- < "$TMPLIST" +AFTER="$(df -Pk "$REAL_DIR" 2>/dev/null | awk 'NR==2{print $4}')" +echo "[自愈] ✓ 清理完成。可用:$((AFTER/1024)) MB(释放 $(((AFTER-BEFORE)/1024)) MB)" diff --git a/src/pages/taskTpl/scriptTemplates/t5_service_restart.sh b/src/pages/taskTpl/scriptTemplates/t5_service_restart.sh new file mode 100644 index 000000000..182991a84 --- /dev/null +++ b/src/pages/taskTpl/scriptTemplates/t5_service_restart.sh @@ -0,0 +1,40 @@ +SERVICE="${1:-}" +WAIT_SEC="${2:-5}" + +if [ -z "$SERVICE" ]; then + echo "[自愈] ✗ 未指定服务名。用法:服务名,,重启后等待秒数" >&2 + exit 2 +fi + +if ! command -v systemctl >/dev/null 2>&1; then + echo "[自愈] ✗ 本机无 systemctl" >&2 + exit 1 +fi + +echo "===== 重启前状态 =====" +systemctl status "$SERVICE" --no-pager -l 2>&1 | head -n 20 + +echo +echo "===== 重启前最近日志 =====" +journalctl -u "$SERVICE" -n 30 --no-pager 2>/dev/null || echo "(journalctl 不可用)" + +echo +echo "[自愈] 正在重启 ${SERVICE} ..." +if ! systemctl restart "$SERVICE"; then + echo "[自愈] ✗ restart 命令失败" >&2 + systemctl status "$SERVICE" --no-pager -l 2>&1 | head -n 20 + exit 1 +fi + +sleep "$WAIT_SEC" + +if systemctl is-active --quiet "$SERVICE"; then + echo "[自愈] ✓ ${SERVICE} 已恢复运行" + systemctl status "$SERVICE" --no-pager -l 2>&1 | head -n 10 + exit 0 +else + echo "[自愈] ✗ ${SERVICE} 重启后仍未运行,需人工介入" >&2 + systemctl status "$SERVICE" --no-pager -l 2>&1 | head -n 20 + journalctl -u "$SERVICE" -n 50 --no-pager 2>/dev/null + exit 1 +fi diff --git a/src/pages/taskTpl/scriptTemplates/t6_log_truncate.sh b/src/pages/taskTpl/scriptTemplates/t6_log_truncate.sh new file mode 100644 index 000000000..ddd935aec --- /dev/null +++ b/src/pages/taskTpl/scriptTemplates/t6_log_truncate.sh @@ -0,0 +1,66 @@ +# 用 truncate 语义(保留 inode),写日志的进程无需重启即可继续写 +LOG_DIR="${1:-/var/log}" +THRESHOLD_MB="${2:-1024}" +DRY_RUN="${3:-1}" + +echo "[自愈] 目录=${LOG_DIR} 阈值=${THRESHOLD_MB}MB DRY_RUN=${DRY_RUN}" + +# 规范化路径,避免符号链接 / ../ / // 等等价写法绕过下面的黑名单 +REAL_DIR="$(cd -P -- "$LOG_DIR" 2>/dev/null && pwd -P)" || { + echo "[自愈] ✗ 目录无效:${LOG_DIR}" >&2 + exit 1 +} + +case "$REAL_DIR" in + /|/etc|/etc/*|/bin|/bin/*|/sbin|/sbin/*|/usr|/usr/*|/lib|/lib/*|/lib64|/lib64/*|/boot|/boot/*|/root|/root/*|/home|/home/*) + echo "[自愈] ✗ 拒绝在受保护路径上执行:${REAL_DIR}" >&2 + exit 1 + ;; +esac + +DEPTH="$(printf '%s' "${REAL_DIR#/}" | awk -F/ '{c=0; for (i=1;i<=NF;i++) if ($i!="") c++; print c}')" +if [ "${DEPTH:-0}" -lt 2 ]; then + echo "[自愈] ✗ 目录层级过浅,拒绝执行:${REAL_DIR}" >&2 + exit 1 +fi + +# 匹配列表与临时目录都放在 root 独占的安全位置(mktemp 默认 /tmp,带 sticky) +TMPLIST="$(mktemp)" || exit 1 +TMPD="$(mktemp -d)" || exit 1 +trap 'rm -rf "$TMPLIST" "$TMPD"' EXIT + +find "$REAL_DIR" -xdev -type f -name '*.log' -size "+${THRESHOLD_MB}M" -print0 > "$TMPLIST" 2>/dev/null + +if [ ! -s "$TMPLIST" ]; then + echo "[自愈] 没有超过 ${THRESHOLD_MB}MB 的 .log 文件" + exit 0 +fi + +echo "===== 待截断文件 =====" +while IFS= read -r -d '' f; do + printf '%s\t%s\n' "$(du -h "$f" 2>/dev/null | cut -f1)" "$f" +done < "$TMPLIST" + +# DRY_RUN 白名单 fail-closed:只有明确的 0 才真正截断 +case "$DRY_RUN" in + 1) + echo "[自愈] DRY_RUN=1,仅预览未截断。确认无误后把第三个参数改为 0 再执行。" + exit 0 + ;; + 0) ;; + *) + echo "[自愈] ✗ DRY_RUN 只接受 0 或 1,当前=${DRY_RUN}" >&2 + exit 2 + ;; +esac + +while IFS= read -r -d '' f; do + # 临时文件落在 root 独占目录,避免 ${f}.keep 落在应用可写目录被软链劫持;成功提示只在真正成功后打印 + if tail -n 1000 "$f" > "$TMPD/keep" 2>/dev/null && cat "$TMPD/keep" > "$f" 2>/dev/null; then + echo "[自愈] ✓ 已截断(保留末尾 1000 行):$f" + else + echo "[自愈] ✗ 截断失败(磁盘满或无权限?):$f" >&2 + fi +done < "$TMPLIST" + +echo "[自愈] 完成" diff --git a/src/pages/taskTpl/scriptTemplates/t7_jvm_dump.sh b/src/pages/taskTpl/scriptTemplates/t7_jvm_dump.sh new file mode 100644 index 000000000..ae816dfbc --- /dev/null +++ b/src/pages/taskTpl/scriptTemplates/t7_jvm_dump.sh @@ -0,0 +1,60 @@ +PROC_KEY="${1:-}" +DUMP_DIR="${2:-/tmp}" + +if [ -z "$PROC_KEY" ]; then + echo "[自愈] ✗ 未指定进程关键字。用法:进程关键字,,dump输出目录" >&2 + exit 2 +fi + +PID="$(pgrep -f "$PROC_KEY" 2>/dev/null | head -n1)" +if [ -z "$PID" ]; then + echo "[自愈] ✗ 未找到匹配进程:${PROC_KEY}" >&2 + exit 1 +fi +echo "[自愈] 目标进程 PID=${PID}" + +mkdir -p "$DUMP_DIR" 2>/dev/null +STAMP="$(date +%Y%m%d-%H%M%S)" + +# 用 mktemp -d 在 DUMP_DIR 下原子创建一个带随机后缀、0700 的专属目录: +# 既保证产物可在已知位置找到,又避免攻击者预置同名软链劫持 root 的写入 +RUN_DIR="$(mktemp -d "${DUMP_DIR%/}/selfheal-${PID}.XXXXXX")" || { + echo "[自愈] ✗ 无法创建 dump 目录:${DUMP_DIR}" >&2 + exit 1 +} +echo "[自愈] 产物目录:${RUN_DIR}" + +echo "===== 1. 保留现场:线程栈 =====" +if command -v jstack >/dev/null 2>&1; then + jstack -l "$PID" > "${RUN_DIR}/jstack-${PID}-${STAMP}.txt" 2>&1 \ + && echo "[自愈] ✓ ${RUN_DIR}/jstack-${PID}-${STAMP}.txt" +else + echo "[自愈] ! 无 jstack,跳过" +fi + +echo "===== 2. 保留现场:堆内存直方图 =====" +if command -v jmap >/dev/null 2>&1; then + jmap -histo:live "$PID" > "${RUN_DIR}/jmap-histo-${PID}-${STAMP}.txt" 2>&1 \ + && echo "[自愈] ✓ ${RUN_DIR}/jmap-histo-${PID}-${STAMP}.txt" + + # 堆 dump 体积大且会 STW,先确认磁盘空间足够(预留 2 倍 RSS) + AVAIL_KB="$(df -Pk "$RUN_DIR" 2>/dev/null | awk 'NR==2{print $4}')" + RSS_KB="$(ps -o rss= -p "$PID" 2>/dev/null | tr -d ' ')" + if [ -n "$AVAIL_KB" ] && [ -n "$RSS_KB" ] && [ "$AVAIL_KB" -gt $((RSS_KB * 2)) ]; then + echo "===== 3. 堆 dump(会 STW,请知悉)=====" + jmap -dump:live,format=b,file="${RUN_DIR}/heap-${PID}-${STAMP}.hprof" "$PID" 2>&1 \ + && echo "[自愈] ✓ ${RUN_DIR}/heap-${PID}-${STAMP}.hprof" + else + echo "[自愈] ! 磁盘空间不足(可用 $((AVAIL_KB/1024))MB,进程 RSS $((RSS_KB/1024))MB),跳过堆 dump" + fi +else + echo "[自愈] ! 无 jmap,跳过" +fi + +echo "===== 4. 重启 =====" +echo "[自愈] !! 本脚本默认只保留现场、不自动重启。" +echo "[自愈] !! 确需自动重启,请取消下面两行的注释,并确认服务有守护进程会拉起。" +# kill -15 "$PID" && sleep 10 +# kill -0 "$PID" 2>/dev/null && kill -9 "$PID" + +echo "[自愈] 现场保留完成,产物在 ${RUN_DIR}" diff --git a/src/pages/taskTpl/scriptTemplates/t8_nginx_reload.sh b/src/pages/taskTpl/scriptTemplates/t8_nginx_reload.sh new file mode 100644 index 000000000..e8a5554a3 --- /dev/null +++ b/src/pages/taskTpl/scriptTemplates/t8_nginx_reload.sh @@ -0,0 +1,38 @@ +NGINX_BIN="${1:-nginx}" + +if ! command -v "$NGINX_BIN" >/dev/null 2>&1; then + echo "[自愈] ✗ 找不到 nginx:${NGINX_BIN}" >&2 + exit 1 +fi + +echo "===== 1. 配置语法校验 =====" +if ! "$NGINX_BIN" -t 2>&1; then + echo "[自愈] ✗ 配置有语法错误,已中止 reload(保持当前配置运行)" >&2 + exit 1 +fi +echo "[自愈] ✓ 配置校验通过" + +echo +echo "===== 2. reload 前 worker 数 =====" +BEFORE="$(pgrep -c -f 'nginx: worker' 2>/dev/null || echo 0)" +echo "worker=${BEFORE}" + +echo +echo "===== 3. 执行 reload =====" +if ! "$NGINX_BIN" -s reload 2>&1; then + echo "[自愈] ✗ reload 失败" >&2 + exit 1 +fi + +sleep 3 + +AFTER="$(pgrep -c -f 'nginx: worker' 2>/dev/null || echo 0)" +echo "[自愈] reload 后 worker=${AFTER}" + +if [ "$AFTER" -gt 0 ]; then + echo "[自愈] ✓ reload 成功" + exit 0 +else + echo "[自愈] ✗ reload 后无 worker 进程,需人工介入" >&2 + exit 1 +fi diff --git a/src/pages/taskTpl/tplForm.tsx b/src/pages/taskTpl/tplForm.tsx index fbad5c3b9..e4e0124d1 100644 --- a/src/pages/taskTpl/tplForm.tsx +++ b/src/pages/taskTpl/tplForm.tsx @@ -14,20 +14,44 @@ * limitations under the License. * */ -import React, { useContext, forwardRef, useImperativeHandle } from 'react'; +import React, { useContext, forwardRef, useImperativeHandle, useMemo, useRef, useState } from 'react'; import _ from 'lodash'; -import { Button, Form, Input, InputNumber, Select, Space, Tag, Radio } from 'antd'; +import { Alert, Button, Form, Input, InputNumber, Select } from 'antd'; import { FormInstance } from 'antd/lib/form'; import { useTranslation } from 'react-i18next'; +import { FileText, Terminal, Server, SlidersHorizontal } from 'lucide-react'; import { CommonStateContext } from '@/App'; +import { scrollToFirstError } from '@/utils'; +import SectionCard, { SectionItem } from '@/pages/alertRules/FormNG/components/SectionCard'; + import Editor from './editor'; import hostsFilterModal from './hostsFilterModal'; +import VarPanel from './components/VarPanel'; +import { SCRIPT_SKELETON } from './constants'; import './style.less'; const FormItem = Form.Item; const { TextArea } = Input; +const NS = 'alertSelfHealing'; + +// 校验失败时,把出错字段映射到所在分区,用于自动展开 +const FIELD_SECTION_MAP: Record = { + title: 'basic', + tags: 'basic', + script: 'script', + args: 'script', + hosts: 'target', + account: 'target', + batch: 'strategy', + tolerance: 'strategy', + timeout: 'strategy', + pause: 'strategy', +}; + +// 骨架的标志是 evt() 函数定义;调用处写作 `evt ident`,不会误判成已定义 +const hasSkeleton = (editor: any) => _.includes(editor.getValue(), 'evt()'); export interface TplFormHandle { getForm: () => FormInstance; @@ -40,7 +64,6 @@ interface TplFormProps { type?: string; bgid?: number; onSubmit?: (values: any) => void; - showAuthLevel?: boolean; } export const defaultInitialValues = { @@ -49,203 +72,337 @@ export const defaultInitialValues = { tolerance: 0, timeout: 30, pause: '', - script: '#!/bin/bash\n# e.g.\nexport PATH=/usr/local/bin:/bin:/usr/bin:/usr/local/sbin:/usr/sbin:/sbin:~/bin\nss -tln', + script: SCRIPT_SKELETON, args: '', tags: undefined, account: 'root', hosts: [], - treeData: [], }; const TplForm = forwardRef(function TplForm(props, ref) { - const { t } = useTranslation('common'); + const { t, i18n } = useTranslation('common'); + const { t: tsh } = useTranslation(NS); const [form] = Form.useForm(); const { businessGroup } = useContext(CommonStateContext); + const aceRef = useRef(null); + + const { initialValues = defaultInitialValues, type = 'tpl', bgid } = props; + + // 分区折叠状态:执行策略默认收起(都是高级项) + const [sectionCollapsed, setSectionCollapsed] = useState>({ + basic: false, + script: false, + target: false, + strategy: true, + }); + + // 摘要与提示需要的字段 const hosts = Form.useWatch('hosts', form); + const titleValue = Form.useWatch('title', form); + const tagsValue = Form.useWatch('tags', form); + const scriptValue = Form.useWatch('script', form); + const argsValue = Form.useWatch('args', form); + const accountValue = Form.useWatch('account', form); + const batchValue = Form.useWatch('batch', form); + const toleranceValue = Form.useWatch('tolerance', form); + const timeoutValue = Form.useWatch('timeout', form); + + const hostCount = useMemo(() => _.size(_.compact(_.map(_.split(_.trim(hosts || ''), '\n'), (h) => _.trim(h)))), [hosts]); + + const sections: SectionItem[] = useMemo( + () => [ + { key: 'basic', title: tsh('section.basic_title'), description: tsh('section.basic_desc'), tag: 'core', icon: }, + { key: 'script', title: tsh('section.script_title'), description: tsh('section.script_desc'), tag: 'core', icon: }, + { key: 'target', title: tsh('section.target_title'), description: tsh('section.target_desc'), tag: 'core', icon: }, + { key: 'strategy', title: tsh('section.strategy_title'), description: tsh('section.strategy_desc'), tag: 'optional', icon: }, + ], + [i18n.language], + ); + + const basicSummary = useMemo(() => { + const parts: string[] = [titleValue || tsh('summary.unnamed')]; + if (type === 'tpl') { + parts.push(_.size(tagsValue) ? tsh('summary.tags_count', { count: _.size(tagsValue) }) : tsh('summary.no_tags')); + } + return parts.join(' · '); + }, [i18n.language, titleValue, tagsValue, type]); + + const scriptSummary = useMemo(() => { + const lines = scriptValue ? _.size(_.split(scriptValue, '\n')) : 0; + const argsCount = argsValue ? _.size(_.split(argsValue, ',,')) : 0; + return [tsh('summary.script_lines', { count: lines }), argsCount ? tsh('summary.args_count', { count: argsCount }) : tsh('summary.no_args')].join(' · '); + }, [i18n.language, scriptValue, argsValue]); + + const targetSummary = useMemo(() => { + return [hostCount ? tsh('summary.hosts_count', { count: hostCount }) : tsh('summary.no_hosts'), accountValue || 'root'].join(' · '); + }, [i18n.language, hostCount, accountValue]); + + const strategySummary = useMemo(() => { + const concurrency = !batchValue ? tsh('summary.concurrency_full') : batchValue === 1 ? tsh('summary.concurrency_seq') : tsh('summary.concurrency_n', { count: batchValue }); + const tol = toleranceValue ? tsh('summary.tolerance_n', { count: toleranceValue }) : tsh('summary.tolerance_none'); + const to = tsh('summary.timeout', { count: _.isNil(timeoutValue) ? 30 : timeoutValue }); + return [concurrency, tol, to].join(' · '); + }, [i18n.language, batchValue, toleranceValue, timeoutValue]); const handleSubmit = (values) => { props.onSubmit?.({ ...values, - hosts: _.split(values.hosts, '\n'), + // 主机列表按行拆分并去掉空行,避免空 textarea 产生 hosts: [''] + hosts: _.compact(_.map(_.split(_.trim(values.hosts || ''), '\n'), (h) => _.trim(h))), + // pause 在表单里是数组,统一在此 join,避免各调用页各写一遍 + pause: _.join(values.pause || [], ','), + // auth_level 无 UI,但后端缺字段会反序列化为 0(=关闭),编辑时须显式回填原值,避免静默清零 + auth_level: values.auth_level ?? initialValues.auth_level ?? 1, }); }; + // 校验失败:展开出错分区后再滚动定位,否则错误项藏在收起的分区里 + const expandErrorSections = (errorFields?: { name: (string | number)[] }[]) => { + const keys = _.compact(_.map(errorFields, ({ name }) => FIELD_SECTION_MAP[_.toString(name?.[0])])); + if (keys.length) { + setSectionCollapsed((prev) => ({ ...prev, ..._.zipObject(keys, _.map(keys, () => false)) })); + } + }; + + const setCollapsed = (key: string) => (collapsed: boolean) => setSectionCollapsed((prev) => ({ ...prev, [key]: collapsed })); + + // 在脚本编辑器光标处插入文本,并同步回表单 + const insertToEditor = (text: string, position?: { row: number; column: number }) => { + const editor = aceRef.current; + if (!editor) return; + editor.session.insert(position ?? editor.getCursorPosition(), text); + editor.focus(); + form.setFieldsValue({ script: editor.getValue() }); + }; + + // 编辑存量脚本时可能没有 evt() 定义,此时直接插入 "$(evt xxx)" 运行期会 command not found + // 并静默取到空串,故先补齐骨架再插入 + const handleInsertVar = (text: string) => { + const editor = aceRef.current; + if (!editor) return; + if (!hasSkeleton(editor)) { + const skeletonRows = _.size(_.split(SCRIPT_SKELETON, '\n')); + insertToEditor(`${SCRIPT_SKELETON}\n`, { row: 0, column: 0 }); + // 骨架插在开头,原光标会随之下移;若仍落在骨架区内(含从未聚焦的 0,0), + // 改插到文末,避免变量被塞进 shebang 之前 + if (editor.getCursorPosition().row < skeletonRows) { + editor.gotoLine(editor.session.getLength(), 0, false); + } + } + insertToEditor(text); + }; + + const handleInsertSkeleton = () => { + const editor = aceRef.current; + if (!editor) return; + // 已有骨架(evt 函数)则不重复插入 + if (hasSkeleton(editor)) return; + insertToEditor(`${SCRIPT_SKELETON}\n`, { row: 0, column: 0 }); + }; + useImperativeHandle(ref, () => ({ getForm: () => form, })); - const { initialValues = defaultInitialValues, type = 'tpl', bgid, showAuthLevel } = props; + const sectionMap = _.keyBy(sections, 'key') as Record; + const sectionKeys = _.map(sections, 'key'); + const idxOf = (key: string) => sectionKeys.indexOf(key); return ( -
- +
+ { + expandErrorSections(errorFields); + scrollToFirstError(); + }} + form={form} + layout='vertical' + >
+ + + {/* 执行目标 */} + + + + {t('task.host.label')} + {t('tpl.host.help')} - {t('tpl.host.help2')} - - - } - name='hosts' - initialValue={_.join(initialValues.hosts, '\n')} - rules={[{ required: type !== 'tpl', message: t('common:required') }]} - > -