perf: 作业中心执行历史增加保留天数配置

pull/11945/head
wangruidong 1 year ago committed by Bryan
parent 6d2a62e413
commit 314257f790

@ -10,6 +10,7 @@ from django.core.files.storage import default_storage
from django.utils import timezone from django.utils import timezone
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from common.const.crontab import CRONTAB_AT_AM_TWO
from common.utils import get_log_keep_day, get_logger from common.utils import get_log_keep_day, get_logger
from common.storage.ftp_file import FTPFileStorageHandler from common.storage.ftp_file import FTPFileStorageHandler
from ops.celery.decorator import ( from ops.celery.decorator import (
@ -20,7 +21,6 @@ from terminal.models import Session, Command
from terminal.backends import server_replay_storage from terminal.backends import server_replay_storage
from .models import UserLoginLog, OperateLog, FTPLog, ActivityLog from .models import UserLoginLog, OperateLog, FTPLog, ActivityLog
logger = get_logger(__name__) logger = get_logger(__name__)
@ -99,7 +99,7 @@ def clean_expired_session_period():
@shared_task(verbose_name=_('Clean audits session task log')) @shared_task(verbose_name=_('Clean audits session task log'))
@register_as_period_task(crontab='0 2 * * *') @register_as_period_task(crontab=CRONTAB_AT_AM_TWO)
@after_app_shutdown_clean_periodic @after_app_shutdown_clean_periodic
def clean_audits_log_period(): def clean_audits_log_period():
print("Start clean audit session task log") print("Start clean audit session task log")

@ -551,6 +551,7 @@ class Config(dict):
'ACTIVITY_LOG_KEEP_DAYS': 180, 'ACTIVITY_LOG_KEEP_DAYS': 180,
'FTP_LOG_KEEP_DAYS': 180, 'FTP_LOG_KEEP_DAYS': 180,
'CLOUD_SYNC_TASK_EXECUTION_KEEP_DAYS': 180, 'CLOUD_SYNC_TASK_EXECUTION_KEEP_DAYS': 180,
'JOB_EXECUTION_KEEP_DAYS': 180,
'TICKETS_ENABLED': True, 'TICKETS_ENABLED': True,

@ -125,6 +125,7 @@ OPERATE_LOG_KEEP_DAYS = CONFIG.OPERATE_LOG_KEEP_DAYS
ACTIVITY_LOG_KEEP_DAYS = CONFIG.ACTIVITY_LOG_KEEP_DAYS ACTIVITY_LOG_KEEP_DAYS = CONFIG.ACTIVITY_LOG_KEEP_DAYS
FTP_LOG_KEEP_DAYS = CONFIG.FTP_LOG_KEEP_DAYS FTP_LOG_KEEP_DAYS = CONFIG.FTP_LOG_KEEP_DAYS
CLOUD_SYNC_TASK_EXECUTION_KEEP_DAYS = CONFIG.CLOUD_SYNC_TASK_EXECUTION_KEEP_DAYS CLOUD_SYNC_TASK_EXECUTION_KEEP_DAYS = CONFIG.CLOUD_SYNC_TASK_EXECUTION_KEEP_DAYS
JOB_EXECUTION_KEEP_DAYS = CONFIG.JOB_EXECUTION_KEEP_DAYS
ORG_CHANGE_TO_URL = CONFIG.ORG_CHANGE_TO_URL ORG_CHANGE_TO_URL = CONFIG.ORG_CHANGE_TO_URL
WINDOWS_SKIP_ALL_MANUAL_PASSWORD = CONFIG.WINDOWS_SKIP_ALL_MANUAL_PASSWORD WINDOWS_SKIP_ALL_MANUAL_PASSWORD = CONFIG.WINDOWS_SKIP_ALL_MANUAL_PASSWORD

@ -8,7 +8,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: PACKAGE VERSION\n" "Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2023-10-19 16:21+0800\n" "POT-Creation-Date: 2023-10-20 14:50+0800\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n" "Language-Team: LANGUAGE <LL@li.org>\n"
@ -95,7 +95,7 @@ msgid "Update"
msgstr "更新" msgstr "更新"
#: accounts/const/account.py:33 #: accounts/const/account.py:33
#: accounts/serializers/automations/change_secret.py:155 audits/const.py:62 #: accounts/serializers/automations/change_secret.py:150 audits/const.py:62
#: audits/signal_handlers/activity_log.py:33 common/const/choices.py:19 #: audits/signal_handlers/activity_log.py:33 common/const/choices.py:19
#: ops/const.py:74 terminal/const.py:77 xpack/plugins/cloud/const.py:43 #: ops/const.py:74 terminal/const.py:77 xpack/plugins/cloud/const.py:43
msgid "Failed" msgid "Failed"
@ -221,8 +221,8 @@ msgstr "ユーザー %s がパスワードを閲覧/導き出しました"
#: accounts/serializers/account/account.py:210 #: accounts/serializers/account/account.py:210
#: accounts/serializers/account/account.py:255 #: accounts/serializers/account/account.py:255
#: accounts/serializers/account/gathered_account.py:10 #: accounts/serializers/account/gathered_account.py:10
#: accounts/serializers/automations/change_secret.py:111 #: accounts/serializers/automations/change_secret.py:106
#: accounts/serializers/automations/change_secret.py:131 #: accounts/serializers/automations/change_secret.py:126
#: acls/serializers/base.py:123 assets/models/asset/common.py:93 #: acls/serializers/base.py:123 assets/models/asset/common.py:93
#: assets/models/asset/common.py:334 assets/models/cmd_filter.py:36 #: assets/models/asset/common.py:334 assets/models/cmd_filter.py:36
#: assets/serializers/domain.py:19 assets/serializers/label.py:27 #: assets/serializers/domain.py:19 assets/serializers/label.py:27
@ -261,8 +261,8 @@ msgid "Source ID"
msgstr "ソース ID" msgstr "ソース ID"
#: accounts/models/account.py:60 #: accounts/models/account.py:60
#: accounts/serializers/automations/change_secret.py:112 #: accounts/serializers/automations/change_secret.py:107
#: accounts/serializers/automations/change_secret.py:132 #: accounts/serializers/automations/change_secret.py:127
#: acls/serializers/base.py:124 acls/templates/acls/asset_login_reminder.html:7 #: acls/serializers/base.py:124 acls/templates/acls/asset_login_reminder.html:7
#: assets/serializers/asset/common.py:125 assets/serializers/gateway.py:28 #: assets/serializers/asset/common.py:125 assets/serializers/gateway.py:28
#: audits/models.py:58 authentication/api/connection_token.py:406 #: audits/models.py:58 authentication/api/connection_token.py:406
@ -343,8 +343,8 @@ msgid "Reason"
msgstr "理由" msgstr "理由"
#: accounts/models/automations/backup_account.py:107 #: accounts/models/automations/backup_account.py:107
#: accounts/serializers/automations/change_secret.py:110 #: accounts/serializers/automations/change_secret.py:105
#: accounts/serializers/automations/change_secret.py:133 #: accounts/serializers/automations/change_secret.py:128
#: ops/serializers/job.py:56 terminal/serializers/session.py:49 #: ops/serializers/job.py:56 terminal/serializers/session.py:49
msgid "Is success" msgid "Is success"
msgstr "成功は" msgstr "成功は"
@ -884,20 +884,20 @@ msgstr "自動スナップショット"
msgid "SSH Key strategy" msgid "SSH Key strategy"
msgstr "SSHキー戦略" msgstr "SSHキー戦略"
#: accounts/serializers/automations/change_secret.py:80 #: accounts/serializers/automations/change_secret.py:79
msgid "* Please enter the correct password length" msgid "* Please enter the correct password length"
msgstr "* 正しいパスワードの長さを入力してください" msgstr "* 正しいパスワードの長さを入力してください"
#: accounts/serializers/automations/change_secret.py:84 #: accounts/serializers/automations/change_secret.py:83
msgid "* Password length range 6-30 bits" msgid "* Password length range 6-30 bits"
msgstr "* パスワードの長さの範囲6-30ビット" msgstr "* パスワードの長さの範囲6-30ビット"
#: accounts/serializers/automations/change_secret.py:114 #: accounts/serializers/automations/change_secret.py:109
#: assets/models/automations/base.py:127 #: assets/models/automations/base.py:127
msgid "Automation task execution" msgid "Automation task execution"
msgstr "自動タスク実行履歴" msgstr "自動タスク実行履歴"
#: accounts/serializers/automations/change_secret.py:154 audits/const.py:61 #: accounts/serializers/automations/change_secret.py:149 audits/const.py:61
#: audits/models.py:63 audits/signal_handlers/activity_log.py:33 #: audits/models.py:63 audits/signal_handlers/activity_log.py:33
#: common/const/choices.py:18 ops/const.py:72 ops/serializers/celery.py:40 #: common/const/choices.py:18 ops/const.py:72 ops/serializers/celery.py:40
#: terminal/const.py:76 terminal/models/session/sharing.py:121 #: terminal/const.py:76 terminal/models/session/sharing.py:121
@ -4199,30 +4199,34 @@ msgstr "終了しました"
msgid "Time cost" msgid "Time cost"
msgstr "時を過ごす" msgstr "時を過ごす"
#: ops/tasks.py:34 #: ops/tasks.py:36
msgid "Run ansible task" msgid "Run ansible task"
msgstr "Ansible タスクを実行する" msgstr "Ansible タスクを実行する"
#: ops/tasks.py:68 #: ops/tasks.py:70
msgid "Run ansible task execution" msgid "Run ansible task execution"
msgstr "Ansible タスクの実行を開始する" msgstr "Ansible タスクの実行を開始する"
#: ops/tasks.py:90 #: ops/tasks.py:92
msgid "Clear celery periodic tasks" msgid "Clear celery periodic tasks"
msgstr "タスクログを定期的にクリアする" msgstr "タスクログを定期的にクリアする"
#: ops/tasks.py:111 #: ops/tasks.py:113
msgid "Create or update periodic tasks" msgid "Create or update periodic tasks"
msgstr "定期的なタスクの作成または更新" msgstr "定期的なタスクの作成または更新"
#: ops/tasks.py:119 #: ops/tasks.py:121
msgid "Periodic check service performance" msgid "Periodic check service performance"
msgstr "サービスのパフォーマンスを定期的に確認する" msgstr "サービスのパフォーマンスを定期的に確認する"
#: ops/tasks.py:125 #: ops/tasks.py:127
msgid "Clean up unexpected jobs" msgid "Clean up unexpected jobs"
msgstr "例外ジョブのクリーンアップ" msgstr "例外ジョブのクリーンアップ"
#: ops/tasks.py:141
msgid "Clean job_execution db record"
msgstr "ジョブセンター実行履歴のクリーンアップ"
#: ops/templates/ops/celery_task_log.html:4 #: ops/templates/ops/celery_task_log.html:4
msgid "Task log" msgid "Task log"
msgstr "タスクログ" msgstr "タスクログ"
@ -5257,14 +5261,18 @@ msgid "Cloud sync record keep days (day)"
msgstr "クラウド同期レコードは日数を保持します(天)" msgstr "クラウド同期レコードは日数を保持します(天)"
#: settings/serializers/cleaning.py:35 #: settings/serializers/cleaning.py:35
msgid "job execution keep days (day)"
msgstr "ジョブセンターの実行履歴 (天) "
#: settings/serializers/cleaning.py:39
msgid "Activity log keep days (day)" msgid "Activity log keep days (day)"
msgstr "活動ログは日数を保持します(天)" msgstr "活動ログは日数を保持します(天)"
#: settings/serializers/cleaning.py:38 #: settings/serializers/cleaning.py:42
msgid "Session keep duration (day)" msgid "Session keep duration (day)"
msgstr "セッション維持期間(天)" msgstr "セッション維持期間(天)"
#: settings/serializers/cleaning.py:40 #: settings/serializers/cleaning.py:44
msgid "" msgid ""
"Session, record, command will be delete if more than duration, only in " "Session, record, command will be delete if more than duration, only in "
"database, OSS will not be affected." "database, OSS will not be affected."
@ -8588,7 +8596,7 @@ msgstr "ライセンスのインポートに成功"
msgid "License is invalid" msgid "License is invalid"
msgstr "ライセンスが無効です" msgstr "ライセンスが無効です"
#: xpack/plugins/license/meta.py:10 xpack/plugins/license/models.py:141 #: xpack/plugins/license/meta.py:10 xpack/plugins/license/models.py:140
msgid "License" msgid "License"
msgstr "ライセンス" msgstr "ライセンス"

@ -7,7 +7,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: JumpServer 0.3.3\n" "Project-Id-Version: JumpServer 0.3.3\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2023-10-19 16:21+0800\n" "POT-Creation-Date: 2023-10-20 14:50+0800\n"
"PO-Revision-Date: 2021-05-20 10:54+0800\n" "PO-Revision-Date: 2021-05-20 10:54+0800\n"
"Last-Translator: ibuler <ibuler@qq.com>\n" "Last-Translator: ibuler <ibuler@qq.com>\n"
"Language-Team: JumpServer team<ibuler@qq.com>\n" "Language-Team: JumpServer team<ibuler@qq.com>\n"
@ -94,7 +94,7 @@ msgid "Update"
msgstr "更新" msgstr "更新"
#: accounts/const/account.py:33 #: accounts/const/account.py:33
#: accounts/serializers/automations/change_secret.py:155 audits/const.py:62 #: accounts/serializers/automations/change_secret.py:150 audits/const.py:62
#: audits/signal_handlers/activity_log.py:33 common/const/choices.py:19 #: audits/signal_handlers/activity_log.py:33 common/const/choices.py:19
#: ops/const.py:74 terminal/const.py:77 xpack/plugins/cloud/const.py:43 #: ops/const.py:74 terminal/const.py:77 xpack/plugins/cloud/const.py:43
msgid "Failed" msgid "Failed"
@ -220,8 +220,8 @@ msgstr "用户 %s 查看/导出 了密码"
#: accounts/serializers/account/account.py:210 #: accounts/serializers/account/account.py:210
#: accounts/serializers/account/account.py:255 #: accounts/serializers/account/account.py:255
#: accounts/serializers/account/gathered_account.py:10 #: accounts/serializers/account/gathered_account.py:10
#: accounts/serializers/automations/change_secret.py:111 #: accounts/serializers/automations/change_secret.py:106
#: accounts/serializers/automations/change_secret.py:131 #: accounts/serializers/automations/change_secret.py:126
#: acls/serializers/base.py:123 assets/models/asset/common.py:93 #: acls/serializers/base.py:123 assets/models/asset/common.py:93
#: assets/models/asset/common.py:334 assets/models/cmd_filter.py:36 #: assets/models/asset/common.py:334 assets/models/cmd_filter.py:36
#: assets/serializers/domain.py:19 assets/serializers/label.py:27 #: assets/serializers/domain.py:19 assets/serializers/label.py:27
@ -260,8 +260,8 @@ msgid "Source ID"
msgstr "来源 ID" msgstr "来源 ID"
#: accounts/models/account.py:60 #: accounts/models/account.py:60
#: accounts/serializers/automations/change_secret.py:112 #: accounts/serializers/automations/change_secret.py:107
#: accounts/serializers/automations/change_secret.py:132 #: accounts/serializers/automations/change_secret.py:127
#: acls/serializers/base.py:124 acls/templates/acls/asset_login_reminder.html:7 #: acls/serializers/base.py:124 acls/templates/acls/asset_login_reminder.html:7
#: assets/serializers/asset/common.py:125 assets/serializers/gateway.py:28 #: assets/serializers/asset/common.py:125 assets/serializers/gateway.py:28
#: audits/models.py:58 authentication/api/connection_token.py:406 #: audits/models.py:58 authentication/api/connection_token.py:406
@ -342,8 +342,8 @@ msgid "Reason"
msgstr "原因" msgstr "原因"
#: accounts/models/automations/backup_account.py:107 #: accounts/models/automations/backup_account.py:107
#: accounts/serializers/automations/change_secret.py:110 #: accounts/serializers/automations/change_secret.py:105
#: accounts/serializers/automations/change_secret.py:133 #: accounts/serializers/automations/change_secret.py:128
#: ops/serializers/job.py:56 terminal/serializers/session.py:49 #: ops/serializers/job.py:56 terminal/serializers/session.py:49
msgid "Is success" msgid "Is success"
msgstr "是否成功" msgstr "是否成功"
@ -881,20 +881,20 @@ msgstr "自动化快照"
msgid "SSH Key strategy" msgid "SSH Key strategy"
msgstr "SSH 密钥更改方式" msgstr "SSH 密钥更改方式"
#: accounts/serializers/automations/change_secret.py:80 #: accounts/serializers/automations/change_secret.py:79
msgid "* Please enter the correct password length" msgid "* Please enter the correct password length"
msgstr "* 请输入正确的密码长度" msgstr "* 请输入正确的密码长度"
#: accounts/serializers/automations/change_secret.py:84 #: accounts/serializers/automations/change_secret.py:83
msgid "* Password length range 6-30 bits" msgid "* Password length range 6-30 bits"
msgstr "* 密码长度范围 6-30 位" msgstr "* 密码长度范围 6-30 位"
#: accounts/serializers/automations/change_secret.py:114 #: accounts/serializers/automations/change_secret.py:109
#: assets/models/automations/base.py:127 #: assets/models/automations/base.py:127
msgid "Automation task execution" msgid "Automation task execution"
msgstr "自动化任务执行历史" msgstr "自动化任务执行历史"
#: accounts/serializers/automations/change_secret.py:154 audits/const.py:61 #: accounts/serializers/automations/change_secret.py:149 audits/const.py:61
#: audits/models.py:63 audits/signal_handlers/activity_log.py:33 #: audits/models.py:63 audits/signal_handlers/activity_log.py:33
#: common/const/choices.py:18 ops/const.py:72 ops/serializers/celery.py:40 #: common/const/choices.py:18 ops/const.py:72 ops/serializers/celery.py:40
#: terminal/const.py:76 terminal/models/session/sharing.py:121 #: terminal/const.py:76 terminal/models/session/sharing.py:121
@ -4149,30 +4149,34 @@ msgstr "是否完成"
msgid "Time cost" msgid "Time cost"
msgstr "花费时间" msgstr "花费时间"
#: ops/tasks.py:34 #: ops/tasks.py:36
msgid "Run ansible task" msgid "Run ansible task"
msgstr "运行 Ansible 任务" msgstr "运行 Ansible 任务"
#: ops/tasks.py:68 #: ops/tasks.py:70
msgid "Run ansible task execution" msgid "Run ansible task execution"
msgstr "开始执行 Ansible 任务" msgstr "开始执行 Ansible 任务"
#: ops/tasks.py:90 #: ops/tasks.py:92
msgid "Clear celery periodic tasks" msgid "Clear celery periodic tasks"
msgstr "清理周期任务" msgstr "清理周期任务"
#: ops/tasks.py:111 #: ops/tasks.py:113
msgid "Create or update periodic tasks" msgid "Create or update periodic tasks"
msgstr "创建或更新周期任务" msgstr "创建或更新周期任务"
#: ops/tasks.py:119 #: ops/tasks.py:121
msgid "Periodic check service performance" msgid "Periodic check service performance"
msgstr "周期检测服务性能" msgstr "周期检测服务性能"
#: ops/tasks.py:125 #: ops/tasks.py:127
msgid "Clean up unexpected jobs" msgid "Clean up unexpected jobs"
msgstr "清理异常作业" msgstr "清理异常作业"
#: ops/tasks.py:141
msgid "Clean job_execution db record"
msgstr "清理作业中心执行历史"
#: ops/templates/ops/celery_task_log.html:4 #: ops/templates/ops/celery_task_log.html:4
msgid "Task log" msgid "Task log"
msgstr "任务列表" msgstr "任务列表"
@ -5202,14 +5206,18 @@ msgid "Cloud sync record keep days (day)"
msgstr "云同步记录 (天)" msgstr "云同步记录 (天)"
#: settings/serializers/cleaning.py:35 #: settings/serializers/cleaning.py:35
msgid "job execution keep days (day)"
msgstr "作业中心执行历史 (天)"
#: settings/serializers/cleaning.py:39
msgid "Activity log keep days (day)" msgid "Activity log keep days (day)"
msgstr "活动记录 (天)" msgstr "活动记录 (天)"
#: settings/serializers/cleaning.py:38 #: settings/serializers/cleaning.py:42
msgid "Session keep duration (day)" msgid "Session keep duration (day)"
msgstr "会话日志 (天)" msgstr "会话日志 (天)"
#: settings/serializers/cleaning.py:40 #: settings/serializers/cleaning.py:44
msgid "" msgid ""
"Session, record, command will be delete if more than duration, only in " "Session, record, command will be delete if more than duration, only in "
"database, OSS will not be affected." "database, OSS will not be affected."
@ -8470,7 +8478,7 @@ msgstr "许可证导入成功"
msgid "License is invalid" msgid "License is invalid"
msgstr "无效的许可证" msgstr "无效的许可证"
#: xpack/plugins/license/meta.py:10 xpack/plugins/license/models.py:141 #: xpack/plugins/license/meta.py:10 xpack/plugins/license/models.py:140
msgid "License" msgid "License"
msgstr "许可证" msgstr "许可证"

@ -1,15 +1,18 @@
# coding: utf-8 # coding: utf-8
import datetime
from celery import shared_task from celery import shared_task
from celery.exceptions import SoftTimeLimitExceeded from celery.exceptions import SoftTimeLimitExceeded
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django.utils import timezone
from django_celery_beat.models import PeriodicTask from django_celery_beat.models import PeriodicTask
from common.utils import get_logger, get_object_or_none from common.const.crontab import CRONTAB_AT_AM_TWO
from common.utils import get_logger, get_object_or_none, get_log_keep_day
from ops.celery import app from ops.celery import app
from orgs.utils import tmp_to_org, tmp_to_root_org from orgs.utils import tmp_to_org, tmp_to_root_org
from .celery.decorator import ( from .celery.decorator import (
register_as_period_task, after_app_ready_start register_as_period_task, after_app_ready_start, after_app_shutdown_clean_periodic
) )
from .celery.utils import ( from .celery.utils import (
create_or_update_celery_periodic_tasks, get_celery_periodic_task, create_or_update_celery_periodic_tasks, get_celery_periodic_task,
@ -127,3 +130,15 @@ def check_server_performance_period():
def clean_up_unexpected_jobs(): def clean_up_unexpected_jobs():
with tmp_to_root_org(): with tmp_to_root_org():
JobExecution.clean_unexpected_execution() JobExecution.clean_unexpected_execution()
@shared_task(verbose_name=_('Clean job_execution db record'))
@register_as_period_task(crontab=CRONTAB_AT_AM_TWO)
@after_app_shutdown_clean_periodic
def clean_job_execution_period():
logger.info("Start clean job_execution db record")
now = timezone.now()
days = get_log_keep_day('JOB_EXECUTION_KEEP_DAYS')
expired_day = now - datetime.timedelta(days=days)
del_res = JobExecution.objects.filter(date_created__lt=expired_day).delete()
logger.info(f"clean job_execution db record success! delete {days} days record, delete result: {del_res}")

@ -30,6 +30,10 @@ class CleaningSerializer(serializers.Serializer):
min_value=MIN_VALUE, max_value=9999, min_value=MIN_VALUE, max_value=9999,
label=_("Cloud sync record keep days (day)"), label=_("Cloud sync record keep days (day)"),
) )
JOB_EXECUTION_KEEP_DAYS = serializers.IntegerField(
min_value=MIN_VALUE, max_value=9999,
label=_("job execution keep days (day)"),
)
ACTIVITY_LOG_KEEP_DAYS = serializers.IntegerField( ACTIVITY_LOG_KEEP_DAYS = serializers.IntegerField(
min_value=MIN_VALUE, max_value=9999, min_value=MIN_VALUE, max_value=9999,
label=_("Activity log keep days (day)"), label=_("Activity log keep days (day)"),

Loading…
Cancel
Save