diff --git a/api_admin/settings.py b/api_admin/settings.py index ec9ee3ad..423d2150 100644 --- a/api_admin/settings.py +++ b/api_admin/settings.py @@ -1,3 +1,5 @@ +import logging + from django.contrib.auth import get_user_model from django.contrib.auth.models import Group from django.db import transaction @@ -11,6 +13,10 @@ validate_go_inception_payload, ) from common.config import SysConfig +from sql.inventory import ( + INVENTORY_REFRESH_INTERVAL_CHOICES, + ensure_inventory_refresh_schedule, +) from sql.engines.mysql_ddl import validate_binary_path from sql.models import InstanceTag, ResourceGroup @@ -18,6 +24,7 @@ from api_core.response import success_response User = get_user_model() +logger = logging.getLogger("default") DEFAULT_CHAT_MODEL = "gpt-3.5-turbo" DEFAULT_QUERY_TEMPLATE = ( @@ -38,6 +45,7 @@ STORAGE_TYPE_OPTIONS = ("local", "sftp", "s3c", "azure") SMS_PROVIDER_OPTIONS = ("disabled", "aliyun", "tencent") TASK_BACKEND_OPTIONS = ("django_q", "celery") +INVENTORY_REFRESH_INTERVAL_OPTIONS = INVENTORY_REFRESH_INTERVAL_CHOICES SYSTEM_SETTINGS_SCHEMA = ( {"name": "go_inception_host", "kind": "string", "default": ""}, @@ -77,6 +85,12 @@ "choices": TASK_BACKEND_OPTIONS, "default": "django_q", }, + { + "name": "inventory_refresh_interval", + "kind": "choice", + "choices": INVENTORY_REFRESH_INTERVAL_OPTIONS, + "default": "24h", + }, {"name": "celery_broker_url", "kind": "string", "default": ""}, {"name": "celery_result_backend", "kind": "string", "default": ""}, { @@ -281,9 +295,22 @@ def build_system_settings_options(): } for backend in TASK_BACKEND_OPTIONS ], + "inventory_refresh_intervals": [ + {"value": interval, "label": interval} + for interval in INVENTORY_REFRESH_INTERVAL_OPTIONS + ], } +def sync_inventory_refresh_schedule(force=False): + try: + ensure_inventory_refresh_schedule(force=force) + return True + except Exception as exc: + logger.exception("Failed to synchronize the inventory refresh schedule.") + return False + + class SystemSettingsSerializer(serializers.Serializer): def get_fields(self): fields = {} @@ -480,6 +507,7 @@ class SystemSettingsView(views.APIView): ) def get(self, request): serializer = SystemSettingsSerializer(instance=load_system_settings()) + sync_inventory_refresh_schedule() return success_response( data={ "settings": serializer.data, @@ -497,12 +525,20 @@ def put(self, request): serializer = SystemSettingsSerializer(data=request.data) serializer.is_valid(raise_exception=True) save_system_settings(serializer.validated_data) + schedule_synced = sync_inventory_refresh_schedule(force=True) response_serializer = SystemSettingsSerializer(instance=load_system_settings()) + detail = "System settings updated successfully." + if not schedule_synced: + detail = ( + "System settings updated, but the inventory refresh schedule " + "could not be synchronized. Check the task backend and try again." + ) return success_response( - detail="System settings updated successfully.", + detail=detail, data={ "settings": response_serializer.data, "options": build_system_settings_options(), + "inventory_refresh_schedule_synced": schedule_synced, }, ) diff --git a/api_core/legacy_tests.py b/api_core/legacy_tests.py index b5d9de75..f7193a01 100644 --- a/api_core/legacy_tests.py +++ b/api_core/legacy_tests.py @@ -13,7 +13,11 @@ from sql.engines import ReviewSet from sql.engines.mysql_ddl import MysqlDDLExecutorError from sql.engines.models import ReviewResult, ResultSet -from api_admin.settings import DEFAULT_CHAT_MODEL, NOTIFY_PHASE_OPTIONS +from api_admin.settings import ( + DEFAULT_CHAT_MODEL, + INVENTORY_REFRESH_INTERVAL_OPTIONS, + NOTIFY_PHASE_OPTIONS, +) from sql.models import ( ResourceGroup, Instance, @@ -112,6 +116,48 @@ def test_get_system_settings_includes_task_backend_options(self): {"value": "celery", "label": "Celery"}, payload["data"]["options"]["task_backends"], ) + self.assertEqual( + payload["data"]["settings"]["inventory_refresh_interval"], "24h" + ) + self.assertEqual( + payload["data"]["options"]["inventory_refresh_intervals"], + [ + {"value": interval, "label": interval} + for interval in INVENTORY_REFRESH_INTERVAL_OPTIONS + ], + ) + + def test_put_system_settings_saves_inventory_refresh_interval(self): + response = self.client.put( + "/api/v1/system-settings/", + data=json.dumps({"inventory_refresh_interval": "6h"}), + content_type="application/json", + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual( + response.json()["data"]["settings"]["inventory_refresh_interval"], "6h" + ) + self.assertTrue(response.json()["data"]["inventory_refresh_schedule_synced"]) + self.assertEqual(self.sys_config.get("inventory_refresh_interval"), "6h") + + @patch("api_admin.settings.sync_inventory_refresh_schedule", return_value=False) + def test_put_system_settings_surfaces_inventory_schedule_sync_warning( + self, _mock_sync + ): + response = self.client.put( + "/api/v1/system-settings/", + data=json.dumps({"inventory_refresh_interval": "12h"}), + content_type="application/json", + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual( + response.json()["detail"], + "System settings updated, but the inventory refresh schedule could not be synchronized. Check the task backend and try again.", + ) + self.assertFalse(response.json()["data"]["inventory_refresh_schedule_synced"]) + self.assertEqual(self.sys_config.get("inventory_refresh_interval"), "12h") def test_put_system_settings_requires_broker_url_for_celery(self): response = self.client.put( @@ -1483,6 +1529,29 @@ def test_get_instance_list(self): self.assertEqual(r.status_code, status.HTTP_200_OK) self.assertEqual(response_data(r)["count"], 1) + def test_get_instance_list_includes_inventory_snapshot_fields(self): + self.ins.inventory_status = Instance.INVENTORY_STATUS_OK + self.ins.inventory_detected_hostname = "detected-host" + self.ins.inventory_detected_version = "8.0.36" + self.ins.inventory_last_success_at = datetime.now() + self.ins.save( + update_fields=[ + "inventory_status", + "inventory_detected_hostname", + "inventory_detected_version", + "inventory_last_success_at", + ] + ) + + r = self.client.get("/api/v1/instance/", format="json") + + self.assertEqual(r.status_code, status.HTTP_200_OK) + payload = response_data(r)["results"][0] + self.assertEqual(payload["inventory_status"], "ok") + self.assertEqual(payload["inventory_detected_hostname"], "detected-host") + self.assertEqual(payload["inventory_detected_version"], "8.0.36") + self.assertIsNotNone(payload["inventory_last_refresh_at"]) + def test_get_instance_list_with_search_and_filters(self): """Search and filters should match legacy inventory behavior.""" read_tag = InstanceTag.objects.create( @@ -2013,7 +2082,7 @@ def test_delete_instance(self): self.assertEqual(Instance.objects.filter(instance_name="some_ins").count(), 0) -class TestPermissionRequestAPI(CacheIsolatedAPITestCase): +class TestPermissionRequestAPI_Legacy(CacheIsolatedAPITestCase): def setUp(self): self.review_group = Group.objects.create(name="Permission Approvers") self.resource_group = ResourceGroup.objects.create(group_name="permission-rg") @@ -2241,38 +2310,49 @@ def test_query_instance_list_includes_temporary_instance_grant(self): def test_test_instance_connection_requires_superuser(self): """Connection testing stays restricted to superusers.""" + self._login(self.requester) r = self.client.post( - f"/api/v1/instance/{self.ins.id}/test-connection/", + f"/api/v1/instance/{self.instance.id}/test-connection/", format="json", ) self.assertEqual(r.status_code, status.HTTP_403_FORBIDDEN) - @patch("api_instances.views.get_engine") + @patch("sql.inventory.get_engine") def test_test_instance_connection(self, mock_get_engine): """Superusers can run the SPA connection test action.""" - self.user.is_superuser = True - self.user.save(update_fields=["is_superuser"]) + self.requester.is_superuser = True + self.requester.save(update_fields=["is_superuser"]) + self._login(self.requester) mock_engine = Mock() mock_result = Mock(error="") mock_engine.test_connection.return_value = mock_result + mock_engine.get_inventory_details.return_value = { + "hostname": "detected-host", + "version": "8.0.36", + } mock_get_engine.return_value = mock_engine r = self.client.post( - f"/api/v1/instance/{self.ins.id}/test-connection/", + f"/api/v1/instance/{self.instance.id}/test-connection/", format="json", ) self.assertEqual(r.status_code, status.HTTP_200_OK) payload = response_data(r) self.assertEqual(payload["success"], True) self.assertEqual(payload["message"], "Connection successful.") + self.instance.refresh_from_db() + self.assertEqual(self.instance.inventory_status, "ok") + self.assertEqual(self.instance.inventory_detected_hostname, "detected-host") + self.assertEqual(self.instance.inventory_detected_version, "8.0.36") @patch("api_instances.views.get_engine") def test_get_instance_resource(self, mock_get_engine): """Test querying instance resources.""" group = ResourceGroup.objects.create(group_name="instance_resource_test") - self.user.resource_group.add(group) - self.ins.resource_group.add(group) + self.query_user.resource_group.add(group) + self.instance.resource_group.add(group) + self._login(self.query_user) mock_engine = Mock() mock_engine.escape_string.side_effect = lambda x: x @@ -2285,7 +2365,7 @@ def test_get_instance_resource(self, mock_get_engine): r = self.client.get( "/api/v1/instance/resource/", - {"instance_id": self.ins.id, "resource_type": "database"}, + {"instance_id": self.instance.id, "resource_type": "database"}, format="json", ) self.assertEqual(r.status_code, status.HTTP_200_OK) diff --git a/api_instances/serializers.py b/api_instances/serializers.py index 4242021f..c8db0a7c 100644 --- a/api_instances/serializers.py +++ b/api_instances/serializers.py @@ -278,6 +278,9 @@ class InstanceDiagnosticKillResultSerializer(serializers.Serializer): class InstanceListSerializer(serializers.ModelSerializer): resource_group_ids = serializers.SerializerMethodField() instance_tag_ids = serializers.SerializerMethodField() + inventory_last_refresh_at = serializers.DateTimeField( + source="inventory_last_success_at", read_only=True + ) def get_resource_group_ids(self, obj): return list( @@ -305,6 +308,10 @@ class Meta: "sid", "resource_group_ids", "instance_tag_ids", + "inventory_status", + "inventory_detected_hostname", + "inventory_detected_version", + "inventory_last_refresh_at", ) diff --git a/api_instances/views.py b/api_instances/views.py index 79fedfa3..dbf01858 100644 --- a/api_instances/views.py +++ b/api_instances/views.py @@ -20,6 +20,10 @@ from rest_framework.response import Response from sql.engines import ResultSet, engine_map, get_engine +from sql.inventory import ( + ensure_inventory_refresh_schedule, + refresh_instance_inventory_snapshot, +) from sql.models import ( Instance, InstanceAccount, @@ -506,6 +510,8 @@ def get_queryset(self): Q(instance_name__icontains=search) | Q(host__icontains=search) | Q(user__icontains=search) + | Q(inventory_detected_hostname__icontains=search) + | Q(inventory_detected_version__icontains=search) ) if search.isdigit(): search_filter |= Q(id=int(search)) @@ -537,6 +543,14 @@ def get_queryset(self): "-user", "type", "-type", + "inventory_status", + "-inventory_status", + "inventory_detected_hostname", + "-inventory_detected_hostname", + "inventory_detected_version", + "-inventory_detected_version", + "inventory_last_success_at", + "-inventory_last_success_at", } if ordering in allowed_ordering: queryset = queryset.order_by(ordering, "id") @@ -551,7 +565,7 @@ def get_queryset(self): name="search", type=OpenApiTypes.STR, location=OpenApiParameter.QUERY, - description="Match instance ID, name, host, or user.", + description="Match instance ID, name, host, user, detected hostname, or detected version.", ), OpenApiParameter( name="type", @@ -584,6 +598,10 @@ def get_queryset(self): permission_required("sql.menu_instance_list", raise_exception=True) ) def get(self, request): + try: + ensure_inventory_refresh_schedule() + except Exception: + logger.exception("Failed to ensure the inventory refresh schedule.") instances = self.filter_queryset(self.get_queryset()) page_ins = self.paginate_queryset(queryset=instances) serializer_obj = self.get_serializer(page_ins, many=True) @@ -2362,8 +2380,7 @@ def post(self, request, pk): raise Http404 try: - query_engine = get_engine(instance=instance) - test_result = query_engine.test_connection() + snapshot_result = refresh_instance_inventory_snapshot(instance=instance) except serializers.ValidationError: raise except Exception: @@ -2372,7 +2389,7 @@ def post(self, request, pk): {"errors": "Unable to connect to instance. Check configuration."} ) - if test_result.error: + if not snapshot_result["success"]: raise serializers.ValidationError( {"errors": "Unable to connect to instance. Check configuration."} ) diff --git a/common/tests.py b/common/tests.py index e911572f..6173a334 100644 --- a/common/tests.py +++ b/common/tests.py @@ -14,7 +14,9 @@ from common.utils.global_info import global_info from common.utils.spa import spa_path_for_workflow, spa_url_for_workflow from sql.engines import EngineBase, ResultSet +from sql import inventory from sql.models import ( + Config, Instance, SqlWorkflow, SqlWorkflowContent, @@ -103,6 +105,161 @@ def test_set_other_data(self): self.assertEqual(archer_config.sys_config["other_config"], "testvalue3") +class InventoryRefreshTests(TestCase): + def setUp(self): + self.sys_config = SysConfig() + self.instance = Instance.objects.create( + instance_name="inventory-test", + type="master", + db_type="mysql", + host="inventory-host", + port=3306, + user="inventory-user", + password="secret", + ) + + def tearDown(self): + TaskSchedule.objects.all().delete() + Instance.objects.all().delete() + self.sys_config.purge() + + @patch("django_q.tasks.schedule") + def test_ensure_inventory_refresh_schedule_creates_single_active_schedule( + self, mock_schedule + ): + inventory.ensure_inventory_refresh_schedule(force=True) + inventory.ensure_inventory_refresh_schedule() + + self.assertEqual( + TaskSchedule.objects.filter( + name=inventory.INVENTORY_REFRESH_SCHEDULE_NAME + ).count(), + 1, + ) + mock_schedule.assert_called_once() + + @patch("django_q.tasks.schedule") + def test_force_schedule_refresh_replaces_next_run_when_interval_changes( + self, mock_schedule + ): + self.sys_config.set("inventory_refresh_interval", "24h") + inventory.ensure_inventory_refresh_schedule(force=True) + first_run = TaskSchedule.objects.get( + name=inventory.INVENTORY_REFRESH_SCHEDULE_NAME + ).run_at + + self.sys_config.set("inventory_refresh_interval", "1h") + inventory.ensure_inventory_refresh_schedule(force=True) + second_run = TaskSchedule.objects.get( + name=inventory.INVENTORY_REFRESH_SCHEDULE_NAME + ).run_at + + self.assertLess(second_run, first_run) + self.assertEqual(mock_schedule.call_count, 2) + self.assertTrue( + Config.objects.filter( + item=inventory.INVENTORY_REFRESH_SCHEDULE_LOCK_NAME + ).exists() + ) + + @patch("django_q.tasks.schedule") + def test_inventory_refresh_task_callback_rearms_schedule(self, mock_schedule): + inventory.inventory_refresh_task_callback(Mock(success=True)) + + self.assertTrue( + TaskSchedule.objects.filter( + name=inventory.INVENTORY_REFRESH_SCHEDULE_NAME + ).exists() + ) + mock_schedule.assert_called_once() + + @patch( + "sql.inventory.collect_inventory_snapshot", + return_value={"hostname": "detected-host", "version": "8.0.36"}, + ) + def test_refresh_instance_inventory_snapshot_marks_ok(self, _collect_snapshot): + result = inventory.refresh_instance_inventory_snapshot(self.instance) + + self.instance.refresh_from_db() + self.assertEqual(result["status"], Instance.INVENTORY_STATUS_OK) + self.assertEqual(self.instance.inventory_status, Instance.INVENTORY_STATUS_OK) + self.assertEqual(self.instance.inventory_detected_hostname, "detected-host") + self.assertEqual(self.instance.inventory_detected_version, "8.0.36") + self.assertIsNotNone(self.instance.inventory_last_attempt_at) + self.assertIsNotNone(self.instance.inventory_last_success_at) + + @patch( + "sql.inventory.collect_inventory_snapshot", + side_effect=[ + {"hostname": "detected-host", "version": "8.0.36"}, + RuntimeError("boom"), + ], + ) + def test_refresh_instance_inventory_snapshot_keeps_last_good_values_when_stale( + self, _collect_snapshot + ): + inventory.refresh_instance_inventory_snapshot(self.instance) + inventory.refresh_instance_inventory_snapshot(self.instance) + + self.instance.refresh_from_db() + self.assertEqual( + self.instance.inventory_status, Instance.INVENTORY_STATUS_STALE + ) + self.assertEqual(self.instance.inventory_detected_hostname, "detected-host") + self.assertEqual(self.instance.inventory_detected_version, "8.0.36") + self.assertIsNotNone(self.instance.inventory_last_success_at) + + @patch("sql.inventory.collect_inventory_snapshot", side_effect=RuntimeError("boom")) + def test_refresh_instance_inventory_snapshot_marks_failed_before_first_success( + self, _collect_snapshot + ): + inventory.refresh_instance_inventory_snapshot(self.instance) + + self.instance.refresh_from_db() + self.assertEqual( + self.instance.inventory_status, Instance.INVENTORY_STATUS_FAILED + ) + self.assertEqual(self.instance.inventory_detected_hostname, "") + self.assertEqual(self.instance.inventory_detected_version, "") + self.assertIsNone(self.instance.inventory_last_success_at) + + def test_engine_base_inventory_details_without_instance_returns_safe_defaults(self): + self.assertEqual( + EngineBase().get_inventory_details(), + {"hostname": "", "version": ""}, + ) + + def test_refresh_inventory_snapshots_maps_status_constants_to_summary_keys(self): + mock_queryset = Mock() + mock_queryset.iterator.return_value = [self.instance] + with patch.object(Instance, "INVENTORY_STATUS_OK", "healthy"): + with patch("sql.inventory.close_old_connections"): + with patch( + "sql.inventory.Instance.objects.order_by", + return_value=mock_queryset, + ): + with patch( + "sql.inventory.refresh_instance_inventory_snapshot", + return_value={"status": "healthy"}, + ): + summary = inventory.refresh_inventory_snapshots() + + self.assertEqual(summary["total"], 1) + self.assertEqual(summary["ok"], 1) + self.assertEqual(summary["stale"], 0) + self.assertEqual(summary["failed"], 0) + + def test_format_inventory_version_normalizes_lists_and_tuples(self): + self.assertEqual( + inventory._format_inventory_version((" 8 ", None, " 0 ", "", " 36 ")), + "8.0.36", + ) + self.assertEqual( + inventory._format_inventory_version([" 2024 ", " 04 ", " 1 "]), + "2024.04.1", + ) + + class SendMessageTest(TestCase): """Message sending tests.""" diff --git a/frontend/src/app/feature-registry.test.ts b/frontend/src/app/feature-registry.test.ts index 81ab98d5..42bf583d 100644 --- a/frontend/src/app/feature-registry.test.ts +++ b/frontend/src/app/feature-registry.test.ts @@ -36,10 +36,12 @@ describe('feature registry', () => { 'dashboard', 'reports', 'inventory', + 'instance-operations', 'workflows', 'archives', 'queries', 'permissions', + 'audit', 'mailbox', 'settings', ]) @@ -51,11 +53,17 @@ describe('feature registry', () => { expect(labels).toEqual([ 'Dashboard', 'Inventory', + 'Data Dictionary', + 'Instance Databases', + 'Instance Accounts', + 'Parameters', + 'Diagnostics', 'Workflows', 'Archives', 'Queries', 'Permission Management', 'Reports', + 'Audit', 'Profile', ]) }) diff --git a/frontend/src/components/ui/data-table/DataTable.vue b/frontend/src/components/ui/data-table/DataTable.vue index 7cd58a53..eade5be7 100644 --- a/frontend/src/components/ui/data-table/DataTable.vue +++ b/frontend/src/components/ui/data-table/DataTable.vue @@ -33,6 +33,7 @@ const props = withDefaults(defineProps<{ searchQuery?: string sortKey?: string sortDirection?: SortDirection + rowClass?: (row: RowRecord) => string }>(), { loading: false, emptyText: 'No records found.', @@ -51,6 +52,7 @@ const props = withDefaults(defineProps<{ searchQuery: undefined, sortKey: undefined, sortDirection: undefined, + rowClass: undefined, }) const emit = defineEmits<{ @@ -165,6 +167,10 @@ function getRowIdentifier(row: RowRecord) { return JSON.stringify(value) } +function getRowClass(row: RowRecord) { + return props.rowClass ? props.rowClass(row) : '' +} + function toggleSort(column: DataTableColumn) { if (!column.sortable) { return @@ -340,7 +346,11 @@ watch(currentSearchQuery, () => { {{ emptyText }} - + (null) const columns: DataTableColumn[] = [ { key: 'id', label: 'ID', sortable: true, defaultVisible: false }, { key: 'instance_name', label: 'Instance', sortable: true, hideable: false }, + { key: 'inventory_status', label: 'Status', sortable: true }, { key: 'type', label: 'Type', sortable: true }, { key: 'db_type', label: 'Database', sortable: true }, { key: 'host', label: 'Host', sortable: true }, + { key: 'inventory_detected_hostname', label: 'Detected Hostname', sortable: true }, + { key: 'inventory_detected_version', label: 'Version', sortable: true }, { key: 'port', label: 'Port', sortable: true }, { key: 'user', label: 'User', sortable: true }, { key: 'actions', label: 'Actions', hideable: false, headerClass: 'w-[12rem]' }, + { key: 'inventory_last_refresh_at', label: 'Last Refresh', sortable: true }, ] const selectClass = @@ -115,7 +119,11 @@ async function loadInstances() { return } - const ordering = sortKey.value ? `${sortDirection.value === 'desc' ? '-' : ''}${sortKey.value}` : undefined + const effectiveSortKey = + sortKey.value === 'inventory_last_refresh_at' ? 'inventory_last_success_at' : sortKey.value + const ordering = effectiveSortKey + ? `${sortDirection.value === 'desc' ? '-' : ''}${effectiveSortKey}` + : undefined const response = await fetchInstanceInventory(requireToken(), { page: currentPage.value, size: pageSize.value, @@ -186,6 +194,56 @@ function handleTagChange(event: Event) { currentPage.value = 1 } +function formatDateTime(value: string | null) { + if (!value) { + return 'Never' + } + + const date = new Date(value) + if (Number.isNaN(date.getTime())) { + return value + } + + return date.toLocaleString() +} + +function inventoryStatusLabel(value: InstanceInventoryRecord['inventory_status']) { + switch (value) { + case 'ok': + return 'OK' + case 'stale': + return 'Stale' + case 'failed': + return 'Failed' + default: + return 'Never' + } +} + +function inventoryStatusBadgeClass(value: InstanceInventoryRecord['inventory_status']) { + switch (value) { + case 'ok': + return 'bg-emerald-100 text-emerald-800' + case 'stale': + return 'bg-amber-100 text-amber-800' + case 'failed': + return 'bg-red-100 text-red-800' + default: + return 'bg-slate-100 text-slate-700' + } +} + +function inventoryRowClass(row: Record) { + const status = row.inventory_status + if (status === 'stale') { + return 'bg-amber-50' + } + if (status === 'failed') { + return 'bg-red-50' + } + return '' +} + onMounted(async () => { await authStore.loadCurrentUser() @@ -310,9 +368,10 @@ watch(searchQuery, () => { :sort-key="sortKey" :sort-direction="sortDirection" :total-rows="totalCount" + :row-class="inventoryRowClass" row-key="id" - search-placeholder="Filter instances by name, host, user, or ID" - :search-keys="['instance_name', 'host', 'user', 'id']" + search-placeholder="Filter instances by name, host, user, ID, detected hostname, or detected version" + :search-keys="['instance_name', 'host', 'user', 'id', 'inventory_detected_hostname', 'inventory_detected_version']" @update:page="currentPage = $event" @update:page-size="handlePageSizeChange" @update:search-query="handleSearchQueryChange" @@ -343,6 +402,12 @@ watch(searchQuery, () => { + + + + diff --git a/frontend/src/features/settings/system-settings.ts b/frontend/src/features/settings/system-settings.ts index c000aa61..c48b3502 100644 --- a/frontend/src/features/settings/system-settings.ts +++ b/frontend/src/features/settings/system-settings.ts @@ -90,6 +90,13 @@ export const systemSettingsSections: SystemSettingsSectionDefinition[] = [ description: 'Choose the async execution backend and configure optional Celery scale-out settings.', fields: [ { key: 'task_backend', label: 'Task backend', input: 'select', optionSource: 'task_backends', defaultValue: 'django_q' }, + { + key: 'inventory_refresh_interval', + label: 'Inventory refresh interval', + input: 'select', + optionSource: 'inventory_refresh_intervals', + defaultValue: '24h', + }, { key: 'celery_broker_url', label: 'Celery broker URL', diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 9866f39c..9c99fdde 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -73,6 +73,7 @@ export type SystemSettingsOptions = { storage_types: SystemSettingsOption[] sms_providers: SystemSettingsOption[] task_backends: SystemSettingsOption[] + inventory_refresh_intervals: SystemSettingsOption[] } export type SystemSettingsPayload = { @@ -199,6 +200,10 @@ export type InstanceInventoryRecord = { sid: string | null resource_group_ids: number[] instance_tag_ids: number[] + inventory_status: 'never' | 'ok' | 'stale' | 'failed' + inventory_detected_hostname: string + inventory_detected_version: string + inventory_last_refresh_at: string | null } export type InstanceOptionRecord = { diff --git a/sql/engines/__init__.py b/sql/engines/__init__.py index 0ccb6ce2..f0a384b0 100644 --- a/sql/engines/__init__.py +++ b/sql/engines/__init__.py @@ -69,6 +69,10 @@ def server_version(self): """Return engine server version as tuple (x, y, z).""" return tuple() + def get_inventory_details(self): + """Return basic inventory facts for a reachable instance.""" + return {"hostname": getattr(self, "host", "") or "", "version": ""} + def processlist(self, command_type, **kwargs) -> ResultSet: """Get connection information.""" return ResultSet() diff --git a/sql/engines/clickhouse.py b/sql/engines/clickhouse.py index 19dac3e7..2295b422 100644 --- a/sql/engines/clickhouse.py +++ b/sql/engines/clickhouse.py @@ -58,8 +58,30 @@ def auto_backup(self): def server_version(self): sql = "select value from system.build_options where name = 'VERSION_FULL';" result = self.query(sql=sql) - version = result.rows[0][0].split(" ")[1] - return tuple([int(n) for n in version.split(".")[:3]]) + if result.error or not result.rows or not result.rows[0]: + logger.warning( + "Failed to fetch ClickHouse server version: %s", + result.error or "empty result", + ) + return tuple() + try: + version = result.rows[0][0].split(" ")[1] + return tuple([int(n) for n in version.split(".")[:3]]) + except (AttributeError, IndexError, TypeError, ValueError) as exc: + logger.warning("Failed to fetch ClickHouse server version: %s", exc) + return tuple() + + def get_inventory_details(self): + default_details = super().get_inventory_details() + result = self.query(sql="select hostName()") + if result.error or not result.rows or not result.rows[0]: + hostname = default_details["hostname"] + else: + hostname = result.rows[0][0] + details = dict(default_details) + details["hostname"] = hostname or default_details["hostname"] + details["version"] = ".".join(map(str, self.server_version)) + return details def get_table_engine(self, tb_name): """Get engine type of a table.""" diff --git a/sql/engines/mssql.py b/sql/engines/mssql.py index c6f3647f..89bf1105 100644 --- a/sql/engines/mssql.py +++ b/sql/engines/mssql.py @@ -19,6 +19,27 @@ class MssqlEngine(EngineBase): name = "MsSQL" info = "MsSQL engine" + def get_inventory_details(self): + default_details = super().get_inventory_details() + result = self.query( + sql=( + "SELECT CAST(SERVERPROPERTY('MachineName') AS varchar(255)), " + "CAST(SERVERPROPERTY('ProductVersion') AS varchar(255))" + ) + ) + if result.error or not result.rows: + logger.warning( + "Failed to fetch MSSQL inventory details for host %s: %s", + default_details["hostname"], + result.error or "empty result", + ) + return default_details + hostname, version = result.rows[0] + return { + "hostname": hostname or default_details["hostname"], + "version": version or default_details["version"], + } + def get_connection(self, db_name=None): if self.conn: return self.conn diff --git a/sql/engines/mysql.py b/sql/engines/mysql.py index 76a2d8e9..8df6986a 100644 --- a/sql/engines/mysql.py +++ b/sql/engines/mysql.py @@ -155,6 +155,33 @@ def numeric_part(s): self._server_version = tuple([numeric_part(n) for n in version.split(".")[:3]]) return self._server_version + def get_inventory_details(self): + default_details = super().get_inventory_details() + conn = self.get_connection() + try: + version = conn.get_server_info() + except Exception as exc: + logger.warning("Failed to fetch MySQL server version: %s", exc) + return default_details + + cursor = None + try: + cursor = conn.cursor() + cursor.execute("SELECT @@hostname") + row = cursor.fetchone() + except Exception as exc: + logger.warning("Failed to fetch MySQL inventory details: %s", exc) + return default_details + finally: + if cursor is not None: + cursor.close() + + hostname = row[0] if row else default_details["hostname"] + details = dict(default_details) + details["hostname"] = hostname or default_details["hostname"] + details["version"] = version + return details + @property def server_info(self): if self._server_info: diff --git a/sql/engines/oracle.py b/sql/engines/oracle.py index 63171158..d03404be 100644 --- a/sql/engines/oracle.py +++ b/sql/engines/oracle.py @@ -108,6 +108,26 @@ def server_version(self): version = conn.version return tuple([n for n in version.split(".")[:3]]) + def get_inventory_details(self): + default_details = super().get_inventory_details() + version = ".".join(self.server_version) + result = self.query(sql="select instance_name from v$instance") + if result.error: + logger.warning( + "Failed to fetch Oracle inventory details for host %s: %s", + default_details["hostname"], + result.error, + ) + details = dict(default_details) + details["version"] = version + return details + + hostname = result.rows[0][0] if result.rows else default_details["hostname"] + details = dict(default_details) + details["hostname"] = hostname or default_details["hostname"] + details["version"] = version + return details + def get_all_databases(self): """Get database list for upper layer. Internally this returns Oracle schema list. diff --git a/sql/engines/pgsql.py b/sql/engines/pgsql.py index dbfbbe6a..4882e5dd 100644 --- a/sql/engines/pgsql.py +++ b/sql/engines/pgsql.py @@ -47,6 +47,24 @@ def get_connection(self, db_name=None): info = "PgSQL engine" + def get_inventory_details(self): + default_details = super().get_inventory_details() + result = self.query( + sql="SELECT COALESCE(inet_server_addr()::text, ''), version();" + ) + if result.error or not result.rows: + logger.warning( + "Failed to fetch PostgreSQL inventory details for host %s: %s", + default_details["hostname"], + result.error or "empty result", + ) + return default_details + hostname, version = result.rows[0] + details = dict(default_details) + details["hostname"] = hostname or default_details["hostname"] + details["version"] = version or default_details["version"] + return details + def get_all_databases(self): """ Get database list. diff --git a/sql/engines/test_mssql.py b/sql/engines/test_mssql.py index 2aca6723..572750e8 100644 --- a/sql/engines/test_mssql.py +++ b/sql/engines/test_mssql.py @@ -88,6 +88,24 @@ def testAllTables(self, mock_query): mock_query.assert_called_once_with(db_name="some_db", sql=ANY) self.assertEqual(tables.rows, ["tb_1", "tb_2"]) + @patch.object(MssqlEngine, "query") + def test_get_inventory_details_returns_fallback_when_query_fails(self, mock_query): + result = ResultSet(rows=[]) + result.error = "boom" + mock_query.return_value = result + new_engine = MssqlEngine(instance=self.ins1) + details = new_engine.get_inventory_details() + self.assertEqual(details, {"hostname": "some_host", "version": ""}) + + @patch.object(MssqlEngine, "query") + def test_get_inventory_details_returns_fallback_when_query_is_empty( + self, mock_query + ): + mock_query.return_value = ResultSet(rows=[]) + new_engine = MssqlEngine(instance=self.ins1) + details = new_engine.get_inventory_details() + self.assertEqual(details, {"hostname": "some_host", "version": ""}) + @patch.object(MssqlEngine, "query") def testAllColumns(self, mock_query): db_result = ResultSet() diff --git a/sql/engines/test_mysql.py b/sql/engines/test_mysql.py index 32db511b..c1cb6539 100644 --- a/sql/engines/test_mysql.py +++ b/sql/engines/test_mysql.py @@ -58,6 +58,54 @@ def testGetConnection(self, connect): new_engine.get_connection() connect.assert_called_once() + @patch.object(MysqlEngine, "get_connection") + def test_get_inventory_details_closes_cursor_and_falls_back_hostname( + self, mock_get_connection + ): + mock_conn = Mock() + mock_cursor = Mock() + mock_conn.cursor.return_value = mock_cursor + mock_conn.get_server_info.return_value = "8.0.36" + mock_cursor.fetchone.return_value = None + mock_get_connection.return_value = mock_conn + + new_engine = MysqlEngine(instance=self.ins1) + details = new_engine.get_inventory_details() + + self.assertEqual(details, {"hostname": "some_host", "version": "8.0.36"}) + mock_cursor.close.assert_called_once() + + @patch.object(MysqlEngine, "get_connection") + def test_get_inventory_details_returns_defaults_when_server_info_fails( + self, mock_get_connection + ): + mock_conn = Mock() + mock_conn.get_server_info.side_effect = RuntimeError("version boom") + mock_get_connection.return_value = mock_conn + + new_engine = MysqlEngine(instance=self.ins1) + details = new_engine.get_inventory_details() + + self.assertEqual(details, {"hostname": "some_host", "version": ""}) + mock_conn.cursor.assert_not_called() + + @patch.object(MysqlEngine, "get_connection") + def test_get_inventory_details_closes_cursor_and_returns_defaults_on_query_error( + self, mock_get_connection + ): + mock_conn = Mock() + mock_cursor = Mock() + mock_conn.cursor.return_value = mock_cursor + mock_conn.get_server_info.return_value = "8.0.36" + mock_cursor.execute.side_effect = RuntimeError("hostname boom") + mock_get_connection.return_value = mock_conn + + new_engine = MysqlEngine(instance=self.ins1) + details = new_engine.get_inventory_details() + + self.assertEqual(details, {"hostname": "some_host", "version": ""}) + mock_cursor.close.assert_called_once() + @patch("MySQLdb.connect") def testQuery(self, connect): cur = Mock() diff --git a/sql/engines/tests.py b/sql/engines/tests.py index cb8e7dac..e967897f 100644 --- a/sql/engines/tests.py +++ b/sql/engines/tests.py @@ -1,6 +1,6 @@ import json from datetime import timedelta, datetime -from unittest.mock import MagicMock, patch, Mock, ANY +from unittest.mock import MagicMock, patch, Mock, ANY, PropertyMock import sqlparse from django.contrib.auth import get_user_model @@ -600,6 +600,41 @@ def test_get_all_databases(self, query): dbs = new_engine.get_all_databases() self.assertListEqual(dbs.rows, ["postgres", "archery"]) + @patch( + "sql.engines.pgsql.PgSQLEngine.query", + return_value=ResultSet(error="boom", rows=[]), + ) + def test_get_inventory_details_returns_fallback_when_query_fails(self, _query): + new_engine = PgSQLEngine(instance=self.ins) + details = new_engine.get_inventory_details() + self.assertEqual(details, {"hostname": "some_host", "version": ""}) + + @patch( + "sql.engines.pgsql.PgSQLEngine.query", + return_value=ResultSet(rows=[]), + ) + def test_get_inventory_details_returns_fallback_when_query_is_empty(self, _query): + new_engine = PgSQLEngine(instance=self.ins) + details = new_engine.get_inventory_details() + self.assertEqual(details, {"hostname": "some_host", "version": ""}) + + @patch( + "sql.engines.pgsql.PgSQLEngine.query", + return_value=ResultSet(rows=[("detected-host", "PostgreSQL 16.1")]), + ) + def test_get_inventory_details_preserves_base_fields(self, _query): + new_engine = PgSQLEngine(instance=self.ins) + with patch.object( + EngineBase, + "get_inventory_details", + return_value={"hostname": "some_host", "version": "", "region": "eu"}, + ): + details = new_engine.get_inventory_details() + self.assertEqual( + details, + {"hostname": "detected-host", "version": "PostgreSQL 16.1", "region": "eu"}, + ) + @patch( "sql.engines.pgsql.PgSQLEngine.query", return_value=ResultSet( @@ -1185,6 +1220,28 @@ def test_engine_base_info(self, _conn): _conn.return_value.version = "12.1.0.2.0" self.assertTupleEqual(new_engine.server_version, ("12", "1", "0")) + @patch.object(OracleEngine, "query") + @patch.object(OracleEngine, "server_version", new_callable=PropertyMock) + def test_get_inventory_details_returns_version_when_query_fails( + self, mock_version, mock_query + ): + mock_query.return_value = ResultSet(error="boom", rows=[]) + mock_version.return_value = ("12", "1", "0") + new_engine = OracleEngine(instance=self.ins) + details = new_engine.get_inventory_details() + self.assertEqual(details, {"hostname": "some_host", "version": "12.1.0"}) + + @patch.object(OracleEngine, "query") + @patch.object(OracleEngine, "server_version", new_callable=PropertyMock) + def test_get_inventory_details_uses_hostname_when_query_succeeds( + self, mock_version, mock_query + ): + mock_query.return_value = ResultSet(rows=[("oracle-host",)]) + mock_version.return_value = ("12", "1", "0") + new_engine = OracleEngine(instance=self.ins) + details = new_engine.get_inventory_details() + self.assertEqual(details, {"hostname": "oracle-host", "version": "12.1.0"}) + @patch("cx_Oracle.connect.cursor.execute") @patch("cx_Oracle.connect.cursor") @patch("cx_Oracle.connect") @@ -2167,6 +2224,12 @@ def test_server_version(self, mock_query): server_version = new_engine.server_version self.assertTupleEqual(server_version, (22, 1, 3)) + @patch.object(ClickHouseEngine, "query") + def test_server_version_returns_empty_tuple_when_query_fails(self, mock_query): + mock_query.return_value = ResultSet(error="boom", rows=[]) + new_engine = ClickHouseEngine(instance=self.ins1) + self.assertTupleEqual(new_engine.server_version, tuple()) + @patch.object(ClickHouseEngine, "query") def test_table_engine(self, mock_query): table_name = "default.tb_test" @@ -2239,6 +2302,28 @@ def testDescribe(self, mock_query): new_engine.describe_table("some_db", "some_db") mock_query.assert_called_once() + @patch.object(ClickHouseEngine, "server_version", new_callable=PropertyMock) + @patch.object(ClickHouseEngine, "query") + def test_get_inventory_details_returns_fallback_when_query_fails( + self, mock_query, mock_version + ): + mock_query.return_value = ResultSet(error="boom", rows=[]) + mock_version.return_value = (22, 1, 3) + new_engine = ClickHouseEngine(instance=self.ins1) + details = new_engine.get_inventory_details() + self.assertEqual(details, {"hostname": "some_host", "version": "22.1.3"}) + + @patch.object(ClickHouseEngine, "server_version", new_callable=PropertyMock) + @patch.object(ClickHouseEngine, "query") + def test_get_inventory_details_returns_fallback_when_row_is_empty( + self, mock_query, mock_version + ): + mock_query.return_value = ResultSet(rows=[tuple()]) + mock_version.return_value = (22, 1, 3) + new_engine = ClickHouseEngine(instance=self.ins1) + details = new_engine.get_inventory_details() + self.assertEqual(details, {"hostname": "some_host", "version": "22.1.3"}) + def test_query_check_wrong_sql(self): new_engine = ClickHouseEngine(instance=self.ins1) wrong_sql = "-- test" diff --git a/sql/inventory.py b/sql/inventory.py new file mode 100644 index 00000000..fc13f33e --- /dev/null +++ b/sql/inventory.py @@ -0,0 +1,170 @@ +import datetime +import logging + +from django.db import transaction +from django.db import close_old_connections +from django.utils import timezone + +from common.config import SysConfig +from common.task_queue import delete_schedule, schedule, task_info +from sql.engines import get_engine +from sql.models import Config, Instance + +logger = logging.getLogger("default") + +INVENTORY_REFRESH_INTERVAL_DEFAULT = "24h" +INVENTORY_REFRESH_INTERVAL_CHOICES = ("1h", "6h", "12h", "24h") +INVENTORY_REFRESH_INTERVAL_DELTAS = { + "1h": datetime.timedelta(hours=1), + "6h": datetime.timedelta(hours=6), + "12h": datetime.timedelta(hours=12), + "24h": datetime.timedelta(hours=24), +} +INVENTORY_REFRESH_SCHEDULE_NAME = "inventory-refresh-global" +INVENTORY_REFRESH_SCHEDULE_LOCK_NAME = "inventory_refresh_schedule_lock" + + +def get_inventory_refresh_interval(): + interval = SysConfig().get( + "inventory_refresh_interval", INVENTORY_REFRESH_INTERVAL_DEFAULT + ) + if interval not in INVENTORY_REFRESH_INTERVAL_CHOICES: + return INVENTORY_REFRESH_INTERVAL_DEFAULT + return interval + + +def calculate_next_inventory_refresh_run(from_time=None): + current = from_time or timezone.now() + return current + INVENTORY_REFRESH_INTERVAL_DELTAS[get_inventory_refresh_interval()] + + +def get_inventory_refresh_schedule(): + return task_info(INVENTORY_REFRESH_SCHEDULE_NAME) + + +def schedule_inventory_refresh(run_at=None): + next_run = run_at or calculate_next_inventory_refresh_run() + with transaction.atomic(): + Config.objects.update_or_create( + item=INVENTORY_REFRESH_SCHEDULE_LOCK_NAME, + defaults={ + "value": "1", + "description": "Internal lock for the inventory refresh scheduler.", + }, + ) + Config.objects.select_for_update().get( + item=INVENTORY_REFRESH_SCHEDULE_LOCK_NAME + ) + delete_schedule(INVENTORY_REFRESH_SCHEDULE_NAME) + schedule( + "sql.inventory.refresh_inventory_snapshots", + hook="sql.inventory.inventory_refresh_task_callback", + name=INVENTORY_REFRESH_SCHEDULE_NAME, + schedule_type="O", + next_run=next_run, + repeats=1, + timeout=None, + ) + return get_inventory_refresh_schedule() + + +def ensure_inventory_refresh_schedule(force=False): + existing_schedule = get_inventory_refresh_schedule() + if existing_schedule and not force: + return existing_schedule + return schedule_inventory_refresh() + + +def _format_inventory_version(value): + if isinstance(value, (list, tuple)): + parts = [] + for part in value: + if part is None: + continue + normalized = str(part).strip() + if normalized: + parts.append(normalized) + return ".".join(parts) + if value in (None, ""): + return "" + return str(value).strip() + + +def _normalize_inventory_details(details): + payload = details or {} + return { + "hostname": str(payload.get("hostname") or "").strip(), + "version": _format_inventory_version(payload.get("version")), + } + + +def collect_inventory_snapshot(instance): + engine = get_engine(instance=instance) + test_result = engine.test_connection() + if getattr(test_result, "error", ""): + raise RuntimeError(test_result.error) + return _normalize_inventory_details(engine.get_inventory_details()) + + +def refresh_instance_inventory_snapshot(instance, now=None): + attempt_time = now or timezone.now() + update_fields = ["inventory_last_attempt_at", "inventory_status"] + instance.inventory_last_attempt_at = attempt_time + + try: + details = collect_inventory_snapshot(instance) + except Exception: + logger.exception( + "Failed to refresh inventory snapshot for instance_id=%s", instance.id + ) + instance.inventory_status = ( + Instance.INVENTORY_STATUS_STALE + if instance.inventory_last_success_at + else Instance.INVENTORY_STATUS_FAILED + ) + instance.save(update_fields=update_fields) + return {"success": False, "status": instance.inventory_status} + + instance.inventory_status = Instance.INVENTORY_STATUS_OK + instance.inventory_last_success_at = attempt_time + instance.inventory_detected_hostname = details["hostname"] + instance.inventory_detected_version = details["version"] + update_fields.extend( + [ + "inventory_last_success_at", + "inventory_detected_hostname", + "inventory_detected_version", + ] + ) + instance.save(update_fields=update_fields) + return { + "success": True, + "status": instance.inventory_status, + "details": details, + } + + +def refresh_inventory_snapshots(): + close_old_connections() + summary = {"total": 0, "ok": 0, "stale": 0, "failed": 0} + status_key_map = { + Instance.INVENTORY_STATUS_OK: "ok", + Instance.INVENTORY_STATUS_STALE: "stale", + Instance.INVENTORY_STATUS_FAILED: "failed", + } + try: + for instance in Instance.objects.order_by("id").iterator(): + summary["total"] += 1 + result = refresh_instance_inventory_snapshot(instance=instance) + summary_key = status_key_map.get(result.get("status"), "failed") + summary[summary_key] += 1 + finally: + close_old_connections() + return summary + + +def inventory_refresh_task_callback(task_result): + try: + ensure_inventory_refresh_schedule(force=True) + except Exception: + logger.exception("Failed to re-arm the inventory refresh schedule.") diff --git a/sql/migrations/0016_instance_inventory_detected_hostname_and_more.py b/sql/migrations/0016_instance_inventory_detected_hostname_and_more.py new file mode 100644 index 00000000..2e40b62d --- /dev/null +++ b/sql/migrations/0016_instance_inventory_detected_hostname_and_more.py @@ -0,0 +1,68 @@ +# Generated by Django 6.0.3 on 2026-04-30 03:22 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("sql", "0015_alter_permission_options"), + ] + + operations = [ + migrations.AddField( + model_name="instance", + name="inventory_detected_hostname", + field=models.CharField( + blank=True, + default="", + max_length=200, + verbose_name="Inventory Detected Hostname", + ), + ), + migrations.AddField( + model_name="instance", + name="inventory_detected_version", + field=models.CharField( + blank=True, + default="", + max_length=200, + verbose_name="Inventory Detected Version", + ), + ), + migrations.AddField( + model_name="instance", + name="inventory_last_attempt_at", + field=models.DateTimeField( + blank=True, + default=None, + null=True, + verbose_name="Inventory Last Attempt At", + ), + ), + migrations.AddField( + model_name="instance", + name="inventory_last_success_at", + field=models.DateTimeField( + blank=True, + default=None, + null=True, + verbose_name="Inventory Last Success At", + ), + ), + migrations.AddField( + model_name="instance", + name="inventory_status", + field=models.CharField( + choices=[ + ("never", "Never"), + ("ok", "OK"), + ("stale", "Stale"), + ("failed", "Failed"), + ], + default="never", + max_length=20, + verbose_name="Inventory Refresh Status", + ), + ), + ] diff --git a/sql/models.py b/sql/models.py index 7ad50192..2cb785fc 100755 --- a/sql/models.py +++ b/sql/models.py @@ -168,6 +168,17 @@ class Instance(models.Model, PasswordMixin): Production instance configuration. """ + INVENTORY_STATUS_NEVER = "never" + INVENTORY_STATUS_OK = "ok" + INVENTORY_STATUS_STALE = "stale" + INVENTORY_STATUS_FAILED = "failed" + INVENTORY_STATUS_CHOICES = ( + (INVENTORY_STATUS_NEVER, "Never"), + (INVENTORY_STATUS_OK, "OK"), + (INVENTORY_STATUS_STALE, "Stale"), + (INVENTORY_STATUS_FAILED, "Failed"), + ) + instance_name = models.CharField("Instance Name", max_length=50, unique=True) type = models.CharField( "Instance Type", @@ -219,6 +230,36 @@ class Instance(models.Model, PasswordMixin): instance_tag = models.ManyToManyField( InstanceTag, verbose_name="Instance Tag", blank=True ) + inventory_status = models.CharField( + "Inventory Refresh Status", + max_length=20, + choices=INVENTORY_STATUS_CHOICES, + default=INVENTORY_STATUS_NEVER, + ) + inventory_last_attempt_at = models.DateTimeField( + "Inventory Last Attempt At", + null=True, + blank=True, + default=None, + ) + inventory_last_success_at = models.DateTimeField( + "Inventory Last Success At", + null=True, + blank=True, + default=None, + ) + inventory_detected_hostname = models.CharField( + "Inventory Detected Hostname", + max_length=200, + default="", + blank=True, + ) + inventory_detected_version = models.CharField( + "Inventory Detected Version", + max_length=200, + default="", + blank=True, + ) create_time = models.DateTimeField("Created Time", auto_now_add=True) update_time = models.DateTimeField("Updated Time", auto_now=True)