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, () => {
+
+
+ {{ inventoryStatusLabel(value as InstanceInventoryRecord['inventory_status']) }}
+
+
+
{{ value }}
@@ -374,6 +439,10 @@ watch(searchQuery, () => {
Superuser only
+
+
+ {{ formatDateTime(value as string | null) }}
+
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)
|