Newer Older
839 lines | 23.763kb
Bogdan Timofte authored 3 months ago
1
package SmartCollector;
2

            
3
use strict;
4
use warnings;
5
use DBI;
6
use JSON::XS;
7
use Time::HiRes qw(time);
8
use File::Slurp;
9
use Config::Simple;
10
use Digest::SHA qw(sha256_hex);
11

            
12
=head1 NAME
13

            
14
SmartCollector - SMART data collection module for autoSMART
15

            
16
=head1 DESCRIPTION
17

            
18
This module handles the collection of SMART data from HDDs identified in Madagascar inventory,
19
processes the data, and stores it in PostgreSQL for long-term analysis and AI predictions.
20

            
21
=head1 SYNOPSIS
22

            
23
    use SmartCollector;
24

            
25
    my $collector = SmartCollector->new(
26
        config_file => '/path/to/smart.conf',
27
        db_config   => '/path/to/database.conf'
28
    );
29

            
30
    # Collect data from all monitored drives
31
    $collector->collect_all();
32

            
33
    # Collect data from specific drive
34
    $collector->collect_drive('/dev/sda');
35

            
36
=cut
37

            
38
sub new {
39
    my ($class, %args) = @_;
40

            
41
    my $self = {
42
        cluster_config => $args{cluster_config} || '/etc/pve/autoSMART/cluster.conf',
43
        local_config   => $args{local_config} || '/etc/default/autosmart',
44
        debug          => $args{debug} || 0,
45
        node_id        => $args{node_id} || `hostname`,
46
        smart_params   => {},
47
        db_handle      => undef,
48
        local_settings => {},
49
    };
50

            
51
    chomp $self->{node_id};
52

            
53
    bless $self, $class;
54
    $self->_load_local_config();
55
    $self->_load_cluster_config();
56
    $self->_connect_database();
57

            
58
    return $self;
59
}
60

            
61
=head2 _load_local_config
62

            
63
Load local node-specific configuration from /etc/default/autosmart
64

            
65
=cut
66

            
67
sub _load_local_config {
68
    my $self = shift;
69

            
70
    return unless -f $self->{local_config};
71

            
72
    open my $fh, '<', $self->{local_config}
73
        or die "Cannot read local config: $self->{local_config}: $!";
74

            
75
    while (my $line = <$fh>) {
76
        chomp $line;
77
        next if $line =~ /^\s*#/ || $line =~ /^\s*$/;
78

            
79
        if ($line =~ /^(\w+)=(.+)$/) {
80
            my ($key, $value) = ($1, $2);
81
            $value =~ s/^["']|["']$//g;  # Remove quotes
82
            $self->{local_settings}->{$key} = $value;
83
        }
84
    }
85

            
86
    close $fh;
87

            
88
    # Apply debug settings
89
    if ($self->{local_settings}->{AUTOSMART_DEBUG_ENABLED} eq 'true') {
90
        $self->{debug} = $self->{local_settings}->{AUTOSMART_DEBUG_LEVEL} || 1;
91
    }
92

            
93
    $self->_log("Loaded local configuration from $self->{local_config}");
94
}
95

            
96
=head2 _load_cluster_config
97

            
98
Load cluster-wide configuration from Proxmox shared storage
99

            
100
=cut
101

            
102
sub _load_cluster_config {
103
    my $self = shift;
104

            
105
    unless (-f $self->{cluster_config}) {
106
        die "Cluster configuration not found: $self->{cluster_config}";
107
    }
108

            
109
    my $cfg = Config::Simple->new($self->{cluster_config})
110
        or die "Cannot load cluster config file: $self->{cluster_config}";
111

            
112
    # Load monitoring settings
113
    $self->{collection_interval} = $cfg->param('cluster.collection_interval')
114
        || $self->{local_settings}->{AUTOSMART_COLLECTION_INTERVAL} || 300;
115
    $self->{collection_timeout} = $cfg->param('cluster.collection_timeout')
116
        || $self->{local_settings}->{AUTOSMART_COLLECTION_TIMEOUT} || 30;
117
    $self->{madagascar_inventory} = $cfg->param('madagascar.inventory_path');
118

            
119
    # Load cluster information
120
    $self->{cluster_name} = $cfg->param('cluster.cluster_name');
121
    $self->{cluster_nodes} = [split /,/, ($cfg->param('cluster.nodes') || '')];
122

            
123
    # Load SMART parameters from cluster config
124
    my @param_keys = $cfg->param(-block => 'smart_parameters');
125

            
126
    foreach my $key (@param_keys) {
127
        my $value = $cfg->param("smart_parameters.$key");
128
        my ($threshold, $weight, $enabled, $description) = split /,/, $value, 4;
129

            
130
        $self->{smart_params}->{$key} = {
131
            threshold   => $threshold,
132
            weight      => $weight,
133
            enabled     => ($enabled eq 'true'),
134
            description => $description,
135
        } if $enabled eq 'true';
136
    }
137

            
138
    $self->_log("Loaded cluster configuration: $self->{cluster_name} (" .
139
                keys(%{$self->{smart_params}}) . " SMART parameters)");
140
}
141

            
142
=head2 _connect_database
143

            
144
Establish PostgreSQL database connection using cluster configuration
145

            
146
=cut
147

            
148
sub _connect_database {
149
    my $self = shift;
150

            
151
    my $cfg = Config::Simple->new($self->{cluster_config})
152
        or die "Cannot load cluster config for database: $self->{cluster_config}";
153

            
154
    my $dsn = sprintf("DBI:Pg:database=%s;host=%s;port=%s",
155
        $cfg->param('database.database'),
156
        $cfg->param('database.host'),
157
        $cfg->param('database.port')
158
    );
159

            
160
    my $timeout = $cfg->param('database.connection_timeout') || 30;
161

            
162
    $self->{db_handle} = DBI->connect(
163
        $dsn,
164
        $cfg->param('database.username'),
165
        $cfg->param('database.password'),
166
        {
167
            RaiseError => 1,
168
            AutoCommit => 1,
169
            pg_enable_utf8 => 1,
170
            connect_timeout => $timeout,
171
        }
172
    ) or die "Database connection failed: $DBI::errstr";
173

            
174
    # Register this node in the cluster
175
    $self->_register_node();
176

            
177
    $self->_log("Database connection established to cluster database");
178
}
179

            
180
=head2 get_madagascar_drives
181

            
182
Get list of HDDs from Madagascar inventory (cluster-shared)
183

            
184
=cut
185

            
186
sub get_madagascar_drives {
187
    my $self = shift;
188

            
189
    return [] unless -f $self->{madagascar_inventory};
190

            
191
    my $inventory_json = read_file($self->{madagascar_inventory});
192
    my $inventory = decode_json($inventory_json);
193

            
194
    my @drives = ();
195

            
196
    # Extract HDD information from Madagascar inventory
197
    if (ref $inventory eq 'HASH' && exists $inventory->{storage}) {
198
        foreach my $storage (@{$inventory->{storage}}) {
199
            # Only include drives for this node
200
            next unless $storage->{node_id} eq $self->{node_id} || !$storage->{node_id};
201
            next unless $storage->{type} eq 'HDD';
202
            next unless $storage->{device_path};
203

            
204
            push @drives, {
205
                device_path => $storage->{device_path},
206
                serial      => $storage->{serial},
207
                model       => $storage->{model},
208
                size_gb     => $storage->{size_gb},
209
                madagascar_id => $storage->{id},
210
                node_id     => $self->{node_id},
211
            };
212
        }
213
    }
214

            
215
    $self->_log("Found " . @drives . " HDDs for node $self->{node_id} in Madagascar inventory");
216
    return \@drives;
217
}
218

            
219
=head2 collect_smart_data
220

            
221
Collect SMART data from a specific drive
222

            
223
=cut
224

            
225
sub collect_smart_data {
226
    my ($self, $device_path) = @_;
227

            
228
    my $cmd = "smartctl -A -f brief -j '$device_path' 2>/dev/null";
229
    my $output = `$cmd`;
230
    my $exit_code = $? >> 8;
231

            
232
    # Parse smartctl JSON output
233
    my $smart_data = {};
234

            
235
    eval {
236
        $smart_data = decode_json($output);
237
    };
238

            
239
    if ($@) {
240
        $self->_log("Failed to parse SMART data for $device_path: $@");
241
        return undef;
242
    }
243

            
244
    return $self->_process_smart_data($smart_data, $device_path);
245
}
246

            
247
=head2 _process_smart_data
248

            
249
Process and normalize SMART data
250

            
251
=cut
252

            
253
sub _process_smart_data {
254
    my ($self, $raw_data, $device_path) = @_;
255

            
256
    my $processed = {
257
        device_path    => $device_path,
258
        timestamp      => time(),
259
        collection_ok  => ($raw_data->{smart_status}->{passed} || 0),
260
        temperature    => 0,
261
        parameters     => {},
262
    };
263

            
264
    # Extract device information
265
    if (exists $raw_data->{device}) {
266
        $processed->{model_name}   = $raw_data->{device}->{model_name} || '';
267
        $processed->{serial_number} = $raw_data->{device}->{serial_number} || '';
268
        $processed->{firmware}     = $raw_data->{device}->{firmware_version} || '';
269
    }
270

            
271
    # Extract temperature
272
    if (exists $raw_data->{temperature}) {
273
        $processed->{temperature} = $raw_data->{temperature}->{current} || 0;
274
    }
275

            
276
    # Extract SMART attributes
277
    if (exists $raw_data->{ata_smart_attributes}->{table}) {
278
        foreach my $attr (@{$raw_data->{ata_smart_attributes}->{table}}) {
279
            my $name = $attr->{name};
280

            
281
            # Only collect monitored parameters
282
            next unless exists $self->{smart_params}->{$name};
283

            
284
            $processed->{parameters}->{$name} = {
285
                id          => $attr->{id},
286
                value       => $attr->{value},
287
                worst       => $attr->{worst},
288
                thresh      => $attr->{thresh},
289
                raw_value   => $attr->{raw}->{value},
290
                when_failed => $attr->{when_failed} || '',
291
                flags       => $attr->{flags}->{string} || '',
292
            };
293
        }
294
    }
295

            
296
    return $processed;
297
}
298

            
299
=head2 store_smart_data
300

            
301
Store processed SMART data using hardware-based tracking with migration detection
302

            
303
=cut
304

            
305
sub store_smart_data {
306
    my ($self, $drive_info, $smart_data) = @_;
307

            
308
    eval {
309
        # Detect/handle HDD migration first
310
        my $hdd_id = $self->_detect_or_create_hdd($drive_info, $smart_data);
Bogdan Timofte authored 2 weeks ago
311

            
312
        # SCHEMA v2: Store complete reading (no differential storage)
313
        my $event_id = $self->_insert_collection_event($hdd_id, $drive_info, $smart_data);
314

            
315
        if ($event_id) {
316
            $self->_log("Stored SMART data: HDD ID $hdd_id, Event ID $event_id (Serial: $smart_data->{serial_number})", 2);
Bogdan Timofte authored 3 months ago
317
        } else {
Bogdan Timofte authored 2 weeks ago
318
            $self->_log("Failed to store SMART data for HDD ID $hdd_id", 1);
Bogdan Timofte authored 3 months ago
319
        }
320
    };
321

            
322
    if ($@) {
323
        $self->_log("ERROR storing SMART data: $@", 1);
324
        return 0;
325
    }
326

            
327
    return 1;
328
}
329

            
330
=head2 _detect_or_create_hdd
331

            
332
Detect HDD migration or create new HDD record using hardware identifiers
333

            
334
=cut
335

            
336
sub _detect_or_create_hdd {
337
    my ($self, $drive_info, $smart_data) = @_;
338

            
339
    my $serial = $smart_data->{serial_number} || 'unknown';
340
    my $model = $smart_data->{model_name} || 'unknown';
341
    my $device_path = $drive_info->{device_path};
342

            
343
    # Call PostgreSQL function to detect migration
344
    my $sth = $self->{db_handle}->prepare(q{
345
        SELECT detect_hdd_migration(?, ?, ?, ?, ?, 'collector')
346
    });
347

            
348
    $sth->execute(
349
        $serial,
350
        $model,
351
        $device_path,
352
        $self->{node_id},
353
        $drive_info->{slot} || undef
354
    );
355

            
356
    my ($hdd_id) = $sth->fetchrow_array();
357

            
358
    # If NULL returned, this is a new HDD - create it
359
    if (!defined $hdd_id) {
360
        $hdd_id = $self->_create_new_hdd($drive_info, $smart_data);
361
        $self->_log("New HDD discovered: $serial ($model) at $device_path", 2);
362
    } else {
363
        $self->_log("HDD tracked: ID $hdd_id, Serial $serial", 3);
364
    }
365

            
366
    return $hdd_id;
367
}
368

            
369
=head2 _create_new_hdd
370

            
371
Create new HDD record with hardware-based identification
372

            
373
=cut
374

            
375
sub _create_new_hdd {
376
    my ($self, $drive_info, $smart_data) = @_;
377

            
378
    my $sql = q{
379
        INSERT INTO hdd_inventory
380
        (serial_number, model_name, firmware, size_gb, manufacturer,
381
         current_device_path, current_node_id, current_slot,
382
         madagascar_id, first_seen, last_seen, status)
383
        VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, NOW(), NOW(), 'active')
384
        RETURNING id
385
    };
386

            
387
    my $sth = $self->{db_handle}->prepare($sql);
388
    $sth->execute(
389
        $smart_data->{serial_number} || 'unknown',
390
        $smart_data->{model_name} || 'unknown',
391
        $smart_data->{firmware} || '',
392
        $drive_info->{size_gb} || 0,
393
        $self->_extract_manufacturer($smart_data->{model_name}),
394
        $drive_info->{device_path},
395
        $self->{node_id},
396
        $drive_info->{slot} || undef,
397
        $drive_info->{madagascar_id}
398
    );
399

            
400
    my ($hdd_id) = $sth->fetchrow_array();
401

            
402
    # Create discovery alert
403
    $self->_create_discovery_alert($hdd_id, $drive_info, $smart_data);
404

            
405
    return $hdd_id;
406
}
407

            
408
=head2 _extract_manufacturer
409

            
410
Extract manufacturer from model name
411

            
412
=cut
413

            
414
sub _extract_manufacturer {
415
    my ($self, $model_name) = @_;
416

            
417
    return 'Unknown' unless $model_name;
418

            
419
    # Common HDD manufacturer patterns
420
    my %manufacturers = (
421
        qr/^WD|Western\s*Digital/i => 'Western Digital',
422
        qr/^ST|Seagate/i           => 'Seagate',
423
        qr/^HGST|Hitachi/i         => 'HGST/Hitachi',
424
        qr/^TOSHIBA/i              => 'Toshiba',
425
        qr/^Samsung/i              => 'Samsung',
426
        qr/^Maxtor/i               => 'Maxtor',
427
        qr/^Fujitsu/i              => 'Fujitsu',
428
    );
429

            
430
    foreach my $pattern (keys %manufacturers) {
431
        return $manufacturers{$pattern} if $model_name =~ /$pattern/;
432
    }
433

            
434
    # Extract first word as fallback
435
    if ($model_name =~ /^(\w+)/) {
436
        return $1;
437
    }
438

            
439
    return 'Unknown';
440
}
441

            
442
=head2 _create_discovery_alert
443

            
444
Create alert for new HDD discovery
445

            
446
=cut
447

            
448
sub _create_discovery_alert {
449
    my ($self, $hdd_id, $drive_info, $smart_data) = @_;
450

            
451
    my $sql = q{
452
        INSERT INTO alert_history
453
        (hdd_id, serial_number, device_path, node_id, alert_type, message)
454
        VALUES (?, ?, ?, ?, 'discovery', ?)
455
    };
456

            
457
    my $message = sprintf(
458
        "New HDD discovered: %s (%s) at %s on node %s - Size: %s GB",
459
        $smart_data->{serial_number} || 'unknown',
460
        $smart_data->{model_name} || 'unknown',
461
        $drive_info->{device_path},
462
        $self->{node_id},
463
        $drive_info->{size_gb} || '?'
464
    );
465

            
466
    $self->{db_handle}->do($sql, undef,
467
        $hdd_id,
468
        $smart_data->{serial_number},
469
        $drive_info->{device_path},
470
        $self->{node_id},
471
        $message
472
    );
473
}
474

            
475
=head2 _should_store_reading
476

            
477
Check if SMART reading should be stored using differential storage logic
478

            
479
=cut
480

            
481
sub _should_store_reading {
482
    my ($self, $hdd_id, $smart_data) = @_;
483

            
484
    # Generate checksum of SMART parameters
485
    my $parameters_json = encode_json($smart_data->{parameters});
486
    my $checksum = sha256_hex($parameters_json . ($smart_data->{temperature} || ''));
487

            
488
    # Call PostgreSQL function to determine if we should store this reading
489
    my $sth = $self->{db_handle}->prepare(q{
490
        SELECT should_store_smart_reading(?, ?, ?, NOW())
491
    });
492

            
493
    $sth->execute($hdd_id, $parameters_json, $checksum);
494

            
495
    my $result = $sth->fetchrow_hashref();
496

            
497
    return {
498
        store => $result->{should_store},
499
        type => $result->{reading_type},
500
        changes_detected => $result->{changes_detected},
501
        changed_parameters => $result->{changed_parameters},
502
        previous_reading_id => $result->{previous_reading_id},
503
        checksum => $checksum
504
    };
505
}
506

            
507
=head2 _insert_smart_reading_differential
508

            
509
Insert SMART reading with differential storage information
510

            
511
=cut
512

            
513
sub _insert_smart_reading_differential {
514
    my ($self, $hdd_id, $drive_info, $smart_data, $storage_info) = @_;
515

            
516
    my $sql = q{
517
        INSERT INTO smart_readings
518
        (hdd_id, serial_number, device_path, node_id, timestamp,
519
         collection_ok, temperature, parameters_json, reading_type,
520
         changes_detected, changed_parameters, previous_reading_id, checksum)
521
        VALUES (?, ?, ?, ?, to_timestamp(?), ?, ?, ?, ?, ?, ?, ?, ?)
522
    };
523

            
524
    # For differential readings, only store changed parameters
525
    my $parameters_to_store;
526
    if ($storage_info->{type} eq 'differential' && $storage_info->{changed_parameters}) {
527
        # Extract only changed parameters
528
        my $changed_params = decode_json($storage_info->{changed_parameters});
529
        my $all_params = $smart_data->{parameters};
530
        $parameters_to_store = {};
531

            
532
        for my $param_name (@$changed_params) {
533
            $parameters_to_store->{$param_name} = $all_params->{$param_name};
534
        }
535
    } else {
536
        # Store all parameters for baseline/full readings
537
        $parameters_to_store = $smart_data->{parameters};
538
    }
539

            
540
    my $parameters_json = encode_json($parameters_to_store);
541

            
542
    $self->{db_handle}->do($sql,
543
        undef,
544
        $hdd_id,
545
        $smart_data->{serial_number},
546
        $drive_info->{device_path},
547
        $self->{node_id},
548
        $smart_data->{timestamp},
549
        $smart_data->{collection_ok},
550
        $smart_data->{temperature},
551
        $parameters_json,
552
        $storage_info->{type},
553
        $storage_info->{changes_detected} ? 'true' : 'false',
554
        $storage_info->{changed_parameters},
555
        $storage_info->{previous_reading_id},
556
        $storage_info->{checksum}
557
    );
558
}
559

            
560
=head2 _insert_smart_reading
561

            
562
Insert SMART reading linked to hardware ID (legacy method for compatibility)
563

            
564
=cut
565

            
566
sub _insert_smart_reading {
567
    my ($self, $hdd_id, $drive_info, $smart_data) = @_;
568

            
569
    my $sql = q{
570
        INSERT INTO smart_readings
571
        (hdd_id, serial_number, device_path, node_id, timestamp,
572
         collection_ok, temperature, parameters_json)
573
        VALUES (?, ?, ?, ?, to_timestamp(?), ?, ?, ?)
574
    };
575

            
576
    my $parameters_json = encode_json($smart_data->{parameters});
577

            
578
    $self->{db_handle}->do($sql,
579
        undef,
580
        $hdd_id,
581
        $smart_data->{serial_number},
582
        $drive_info->{device_path},
583
        $self->{node_id},
584
        $smart_data->{timestamp},
585
        $smart_data->{collection_ok},
586
        $smart_data->{temperature},
587
        $parameters_json
588
    );
589
}
590

            
591
=head2 collect_all
592

            
593
Collect SMART data from all drives in Madagascar inventory
594

            
595
=cut
596

            
597
sub collect_all {
598
    my $self = shift;
599

            
600
    my $drives = $self->get_madagascar_drives();
601
    my $successful = 0;
602
    my $failed = 0;
603
    my $storage_stats = {
604
        baseline => 0,
605
        full => 0,
606
        differential => 0,
607
        skipped => 0
608
    };
609

            
610
    foreach my $drive (@$drives) {
611
        my $smart_data = $self->collect_smart_data($drive->{device_path});
612

            
613
        if ($smart_data && $self->store_smart_data($drive, $smart_data)) {
614
            $successful++;
615
        } else {
616
            $failed++;
617
            $self->_log("Failed to collect/store data for $drive->{device_path}");
618
        }
619

            
620
        # Small delay between drives to avoid overwhelming system
621
        select(undef, undef, undef, 0.1);
622
    }
623

            
624
    # Get storage statistics for this collection run
625
    my $stats = $self->_get_recent_storage_stats();
626
    $self->_log("Collection complete: $successful successful, $failed failed");
627
    $self->_log("Storage efficiency - Baseline: $stats->{baseline}, Full: $stats->{full}, Differential: $stats->{differential}, Skipped: $stats->{skipped}");
628

            
629
    return {
630
        successful => $successful,
631
        failed => $failed,
632
        total => scalar(@$drives),
633
        storage_stats => $stats
634
    };
635
}
636

            
637
=head2 _get_recent_storage_stats
638

            
639
Get statistics about storage efficiency from recent readings
640

            
641
=cut
642

            
643
sub _get_recent_storage_stats {
644
    my $self = shift;
645

            
646
    my $sql = q{
647
        SELECT
648
            reading_type,
649
            COUNT(*) as count
650
        FROM smart_readings
651
        WHERE timestamp > NOW() - INTERVAL '1 hour'
652
        GROUP BY reading_type
653
        ORDER BY reading_type
654
    };
655

            
656
    my $sth = $self->{db_handle}->prepare($sql);
657
    $sth->execute();
658

            
659
    my $stats = {
660
        baseline => 0,
661
        full => 0,
662
        differential => 0,
663
        total => 0
664
    };
665

            
666
    while (my $row = $sth->fetchrow_hashref()) {
667
        $stats->{$row->{reading_type}} = $row->{count};
668
        $stats->{total} += $row->{count};
669
    }
670

            
671
    # Calculate efficiency percentage
672
    my $efficient_readings = $stats->{differential} + $stats->{baseline};
673
    my $efficiency_pct = $stats->{total} > 0 ?
674
        sprintf("%.1f", ($efficient_readings / $stats->{total}) * 100) : 0;
675

            
676
    $stats->{efficiency_percent} = $efficiency_pct;
677

            
678
    return $stats;
679
}
680

            
681
=head2 _log
682

            
683
Internal logging method with enhanced debug levels
684

            
685
=cut
686

            
687
sub _log {
688
    my ($self, $message, $level) = @_;
689

            
690
    $level ||= 1;  # Default to basic level
691

            
692
    # Check if we should log based on debug level
693
    return unless $self->{debug} >= $level;
694

            
695
    my $timestamp = scalar(localtime());
696
    my $node_id = $self->{node_id} || 'unknown';
697
    my $prefix = "[$timestamp] [$node_id] SmartCollector";
698

            
699
    if ($self->{debug}) {
700
        print "$prefix: $message\n";
701
    }
702

            
703
    # Also log to syslog if enabled
704
    if ($self->{local_settings}->{AUTOSMART_LOG_SYSLOG} eq 'true') {
705
        eval {
706
            use Sys::Syslog qw(:standard :macros);
707
            my $facility = $self->{local_settings}->{AUTOSMART_LOG_FACILITY} || 'daemon';
708
            openlog('autosmart', 'pid,ndelay', $facility);
709
            syslog(LOG_INFO, "SmartCollector[$node_id]: $message");
710
            closelog();
711
        };
712
    }
713

            
714
    # Log to file if specified
715
    my $log_file = $self->{local_settings}->{AUTOSMART_DEBUG_LOG_FILE};
716
    if ($log_file && $self->{debug} >= 2) {
717
        eval {
718
            open my $fh, '>>', $log_file;
719
            print $fh "$prefix: $message\n";
720
            close $fh;
721
        };
722
    }
723
}
724

            
725
=head2 _register_node
726

            
727
Register this node in the cluster database
728

            
729
=cut
730

            
731
sub _register_node {
732
    my $self = shift;
733

            
734
    eval {
735
        # Create cluster_nodes table if it doesn't exist
736
        $self->{db_handle}->do(q{
737
            CREATE TABLE IF NOT EXISTS cluster_nodes (
738
                node_id VARCHAR(100) PRIMARY KEY,
739
                hostname VARCHAR(255),
740
                ip_address INET,
741
                last_seen TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
742
                status VARCHAR(20) DEFAULT 'active',
743
                version VARCHAR(50),
744
                capabilities JSON,
745
                created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
746
            )
747
        });
748

            
749
        # Register/update this node
750
        my $hostname = `hostname -f`;
751
        chomp $hostname;
752

            
753
        my $ip = `hostname -I | awk '{print \$1}'`;
754
        chomp $ip;
755

            
756
        $self->{db_handle}->do(q{
757
            INSERT INTO cluster_nodes
758
            (node_id, hostname, ip_address, last_seen, status, version)
759
            VALUES (?, ?, ?, NOW(), 'active', '1.0')
760
            ON CONFLICT (node_id)
761
            DO UPDATE SET
762
                hostname = EXCLUDED.hostname,
763
                ip_address = EXCLUDED.ip_address,
764
                last_seen = NOW(),
765
                status = 'active'
766
        }, undef, $self->{node_id}, $hostname, $ip);
767

            
768
        $self->_log("Registered node $self->{node_id} in cluster", 2);
769
    };
770

            
771
    if ($@) {
772
        $self->_log("Warning: Failed to register node: $@", 1);
773
    }
774
}
775

            
Bogdan Timofte authored 2 weeks ago
776
=head2 _insert_collection_event (SCHEMA v2)
777

            
778
SCHEMA v2: Insert complete SMART reading (no differential storage)
779
Calls PostgreSQL function: insert_collection_event()
780

            
781
=cut
782

            
783
sub _insert_collection_event {
784
    my ($self, $hdd_id, $drive_info, $smart_data) = @_;
785

            
786
    # Build parameters JSON for database function
787
    my $params_json = encode_json($smart_data->{parameters} || {});
788
    my $checksum = sha256_hex($params_json . ($smart_data->{temperature} || ''));
789

            
790
    # Call PostgreSQL function: insert_collection_event(hdd_id, serial, node, ts, temp, ok, checksum, params::JSONB)
791
    my $sth = $self->{db_handle}->prepare(q{
792
        SELECT insert_collection_event(?, ?, ?, NOW(), ?, ?, ?, ?::jsonb)
793
    });
794

            
795
    eval {
796
        $sth->execute(
797
            $hdd_id,
798
            $smart_data->{serial_number},
799
            $self->{node_id},
800
            $smart_data->{temperature} || undef,
801
            1,  # collection_ok = true
802
            $checksum,
803
            $params_json
804
        );
805
    };
806

            
807
    if ($@) {
808
        $self->_log("ERROR inserting collection event: $@", 1);
809
        return undef;
810
    }
811

            
812
    my ($event_id) = $sth->fetchrow_array();
813
    return $event_id;
814
}
815

            
Bogdan Timofte authored 3 months ago
816
=head2 DESTROY
817

            
818
Cleanup database connection
819

            
820
=cut
821

            
822
sub DESTROY {
823
    my $self = shift;
824
    $self->{db_handle}->disconnect() if $self->{db_handle};
825
}
826

            
827
1;
828

            
829
__END__
830

            
831
=head1 AUTHOR
832

            
833
AutoSMART Development Team
834

            
835
=head1 LICENSE
836

            
837
This software is part of the autoSMART project.
838

            
839
=cut