Просмотр исходного кода

Add mobile inventory app and API status checks

Tim Jones 1 день назад
Родитель
Сommit
2f9f948abd

+ 3 - 0
.vscode/settings.json

@@ -0,0 +1,3 @@
+{
+    "liveServer.settings.port": 5501
+}

+ 14 - 0
RackPeek.Web/Api/ApiStatusEndpoints.cs

@@ -0,0 +1,14 @@
+using RackPeek.Domain;
+
+namespace RackPeek.Web.Api;
+
+public static class ApiStatusEndpoints {
+    public const string ApiVersion = "1";
+
+    public static void MapApiStatus(this WebApplication app) {
+        app.MapGet("/api/status", () => Results.Ok(new {
+            apiVersion = ApiVersion,
+            appVersion = RpkConstants.Version
+        }));
+    }
+}

+ 1 - 0
RackPeek.Web/Program.cs

@@ -137,6 +137,7 @@ public class Program {
         app.UseStatusCodePagesWithReExecute("/not-found", createScopeForStatusCodePages: true);
         app.UseAntiforgery();
 
+        app.MapApiStatus();
         app.MapInventoryApi();
         app.MapInventoryReadApi();
 

+ 22 - 0
Tests/Api/ApiStatusEndpointTests.cs

@@ -0,0 +1,22 @@
+using System.Net;
+using System.Text.Json;
+using RackPeek.Domain;
+using RackPeek.Web.Api;
+using Xunit.Abstractions;
+
+namespace Tests.Api;
+
+[Collection("Process-wide static state")]
+public class ApiStatusEndpointTests(ITestOutputHelper output) : ApiTestBase(output) {
+    [Fact]
+    public async Task Status_Returns_Api_And_App_Versions_Without_An_Api_Key() {
+        HttpResponseMessage response = await CreateClient().GetAsync("/api/status");
+
+        Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+        using var document = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
+        Assert.Equal(ApiStatusEndpoints.ApiVersion,
+            document.RootElement.GetProperty("apiVersion").GetString());
+        Assert.Equal(RpkConstants.Version,
+            document.RootElement.GetProperty("appVersion").GetString());
+    }
+}

+ 104 - 20
mobile/lib/data/api_client.dart

@@ -4,6 +4,47 @@ import 'package:http/http.dart' as http;
 
 import '../models/inventory.dart';
 
+List<String> serverUrlCandidates(String address) {
+  final input = address.trim();
+  if (input.isEmpty) return const [];
+
+  final hasScheme = RegExp(r'^[a-zA-Z][a-zA-Z0-9+.-]*://').hasMatch(input);
+  final prefixes = hasScheme ? const [''] : const ['https://', 'http://'];
+  final candidates = <String>[];
+
+  for (final prefix in prefixes) {
+    final uri = Uri.tryParse('$prefix$input');
+    if (uri == null ||
+        !{'http', 'https'}.contains(uri.scheme.toLowerCase()) ||
+        uri.host.isEmpty ||
+        uri.userInfo.isNotEmpty ||
+        uri.hasQuery ||
+        uri.hasFragment) {
+      continue;
+    }
+
+    final normalized = uri
+        .replace(path: uri.path.replaceAll(RegExp(r'/+$'), ''))
+        .toString()
+        .replaceAll(RegExp(r'/+$'), '');
+    if (!candidates.contains(normalized)) candidates.add(normalized);
+  }
+
+  return candidates;
+}
+
+class ApiStatus {
+  const ApiStatus({required this.apiVersion, required this.appVersion});
+
+  final String apiVersion;
+  final String appVersion;
+
+  factory ApiStatus.fromJson(Map<String, dynamic> json) => ApiStatus(
+    apiVersion: json['apiVersion']?.toString() ?? '',
+    appVersion: json['appVersion']?.toString() ?? '',
+  );
+}
+
 class ApiException implements Exception {
   ApiException(this.message, {this.statusCode = 0});
   final String message;
@@ -37,13 +78,46 @@ class RackPeekApi {
     return InventoryDocument.fromJson(json);
   }
 
+  Future<ApiStatus> getStatus() async {
+    final response = await _send(
+      () =>
+          _client.get(_uri('/api/status')).timeout(const Duration(seconds: 5)),
+    );
+    if (response.statusCode != 200) {
+      throw ApiException(
+        'No RackPeek API found at this address (${response.statusCode}).',
+        statusCode: response.statusCode,
+      );
+    }
+
+    try {
+      final json = jsonDecode(response.body) as Map<String, dynamic>;
+      final status = ApiStatus.fromJson(json);
+      if (status.apiVersion.isEmpty) {
+        throw const FormatException('Missing API version.');
+      }
+      return status;
+    } on FormatException {
+      throw ApiException('The server returned an invalid API status response.');
+    } on TypeError {
+      throw ApiException('The server returned an invalid API status response.');
+    }
+  }
+
   Future<Map<String, dynamic>> getSummary() async {
     return _getJson('/api/inventory/summary');
   }
 
-  Future<List<Map<String, dynamic>>> search(String query, {int max = 20}) async {
-    final json = await _getJson('/api/inventory/search', {'q': query, 'max': '$max'});
-    return (json['matches'] as List<dynamic>? ?? []).cast<Map<String, dynamic>>();
+  Future<List<Map<String, dynamic>>> search(
+    String query, {
+    int max = 20,
+  }) async {
+    final json = await _getJson('/api/inventory/search', {
+      'q': query,
+      'max': '$max',
+    });
+    return (json['matches'] as List<dynamic>? ?? [])
+        .cast<Map<String, dynamic>>();
   }
 
   Future<Map<String, dynamic>> upsert(
@@ -56,27 +130,32 @@ class RackPeekApi {
       'dryRun': dryRun,
     });
 
-    final response = await _send(() => _client.post(
-          _uri('/api/inventory'),
-          headers: {..._headers, 'Content-Type': 'application/json'},
-          body: body,
-        ));
+    final response = await _send(
+      () => _client.post(
+        _uri('/api/inventory'),
+        headers: {..._headers, 'Content-Type': 'application/json'},
+        body: body,
+      ),
+    );
 
     return _decode(response);
   }
 
-  Future<Map<String, dynamic>> _getJson(String path,
-      [Map<String, String>? query]) async {
-    final response =
-        await _send(() => _client.get(_uri(path, query), headers: _headers));
+  Future<Map<String, dynamic>> _getJson(
+    String path, [
+    Map<String, String>? query,
+  ]) async {
+    final response = await _send(
+      () => _client.get(_uri(path, query), headers: _headers),
+    );
     return _decode(response);
   }
 
   Future<http.Response> _send(Future<http.Response> Function() send) async {
     try {
       return await send();
-    } on http.ClientException catch (e) {
-      throw ApiException('Cannot reach the server: ${e.message}');
+    } on Exception catch (e) {
+      throw ApiException('Cannot reach the server: $e');
     }
   }
 
@@ -94,15 +173,20 @@ class RackPeekApi {
     final serverError = parsed?['error'] as String?;
     switch (response.statusCode) {
       case 401:
-        throw ApiException('Unauthorized — check the API key.',
-            statusCode: 401);
+        throw ApiException(
+          'Unauthorized — check the API key.',
+          statusCode: 401,
+        );
       case 503:
         throw ApiException(
-            'The server has no API key configured (RPK_API_KEY).',
-            statusCode: 503);
+          'The server has no API key configured (RPK_API_KEY).',
+          statusCode: 503,
+        );
       default:
-        throw ApiException(serverError ?? 'Request failed (${response.statusCode}).',
-            statusCode: response.statusCode);
+        throw ApiException(
+          serverError ?? 'Request failed (${response.statusCode}).',
+          statusCode: response.statusCode,
+        );
     }
   }
 }

+ 183 - 13
mobile/lib/screens/connection_screen.dart

@@ -1,4 +1,7 @@
+import 'dart:async';
+
 import 'package:flutter/material.dart';
+import 'package:http/http.dart' as http;
 
 import '../data/api_client.dart';
 import '../data/settings_store.dart';
@@ -18,23 +21,99 @@ class ConnectionScreen extends StatefulWidget {
 class _ConnectionScreenState extends State<ConnectionScreen> {
   final _baseUrl = TextEditingController();
   final _apiKey = TextEditingController();
+  final http.Client _probeClient = http.Client();
+  Timer? _probeTimer;
+  int _probeToken = 0;
   bool _busy = false;
+  bool _probing = false;
+  bool _showApiKey = false;
+  bool _statusUnavailable = false;
   String? _error;
+  String? _reachabilityError;
+  ApiStatus? _serverStatus;
 
   @override
   void dispose() {
+    _probeTimer?.cancel();
     _baseUrl.dispose();
     _apiKey.dispose();
+    _probeClient.close();
     super.dispose();
   }
 
+  void _onAddressChanged(String value) {
+    _probeTimer?.cancel();
+    final token = ++_probeToken;
+    final canProbe = serverUrlCandidates(value).isNotEmpty;
+    setState(() {
+      _probing = canProbe;
+      _serverStatus = null;
+      _statusUnavailable = false;
+      _reachabilityError = null;
+      _error = null;
+    });
+    if (!canProbe) return;
+
+    _probeTimer = Timer(const Duration(milliseconds: 600), () {
+      _checkAddress(value, token);
+    });
+  }
+
+  Future<({String baseUrl, ApiStatus? status})> _probeAddress(
+    String address,
+  ) async {
+    final candidates = serverUrlCandidates(address);
+    if (candidates.isEmpty) {
+      throw ApiException('Enter a server address, optionally with a port.');
+    }
+
+    ApiException? lastError;
+    for (final candidate in candidates) {
+      try {
+        final status = await RackPeekApi(
+          baseUrl: candidate,
+          apiKey: '',
+          client: _probeClient,
+        ).getStatus();
+        return (baseUrl: candidate, status: status);
+      } on ApiException catch (e) {
+        if (e.statusCode == 404) {
+          return (baseUrl: candidate, status: null);
+        }
+        lastError = e;
+      }
+    }
+    throw lastError ?? ApiException('Could not reach a RackPeek server.');
+  }
+
+  Future<void> _checkAddress(String address, int token) async {
+    try {
+      final result = await _probeAddress(address);
+      if (!mounted || token != _probeToken) return;
+      setState(() {
+        _serverStatus = result.status;
+        _statusUnavailable = result.status == null;
+        _reachabilityError = null;
+        _probing = false;
+      });
+    } on ApiException catch (e) {
+      if (!mounted || token != _probeToken) return;
+      setState(() {
+        _reachabilityError = e.message;
+        _serverStatus = null;
+        _statusUnavailable = false;
+        _probing = false;
+      });
+    }
+  }
+
   Future<void> _connect() async {
-    final baseUrl = _baseUrl.text.trim().replaceAll(RegExp(r'/+$'), '');
+    final address = _baseUrl.text.trim();
     final apiKey = _apiKey.text.trim();
 
-    if (baseUrl.isEmpty || !baseUrl.startsWith('http')) {
+    if (serverUrlCandidates(address).isEmpty) {
       setState(
-        () => _error = 'Enter the server URL, e.g. http://192.168.1.10:8080',
+        () => _error = 'Enter a server address, optionally with a port.',
       );
       return;
     }
@@ -43,31 +122,108 @@ class _ConnectionScreenState extends State<ConnectionScreen> {
       return;
     }
 
+    _probeTimer?.cancel();
+    final token = ++_probeToken;
     setState(() {
       _busy = true;
+      _probing = true;
       _error = null;
+      _reachabilityError = null;
     });
 
     try {
-      await RackPeekApi(baseUrl: baseUrl, apiKey: apiKey).getSummary();
-      await widget.settings.save(baseUrl, apiKey);
+      final result = await _probeAddress(address);
+      if (!mounted || token != _probeToken) return;
+      setState(() {
+        _serverStatus = result.status;
+        _statusUnavailable = result.status == null;
+        _probing = false;
+      });
+
+      await RackPeekApi(baseUrl: result.baseUrl, apiKey: apiKey).getSummary();
+      await widget.settings.save(result.baseUrl, apiKey);
       if (!mounted) return;
       Navigator.of(context).pushReplacement(
         MaterialPageRoute(
           builder: (_) => HomeShell(
             settings: widget.settings,
-            baseUrl: baseUrl,
+            baseUrl: result.baseUrl,
             apiKey: apiKey,
           ),
         ),
       );
     } on ApiException catch (e) {
-      setState(() => _error = e.message);
+      if (!mounted) return;
+      setState(() {
+        _error = e.message;
+        _probing = false;
+      });
     } finally {
       if (mounted) setState(() => _busy = false);
     }
   }
 
+  Widget _reachabilityIndicator() {
+    if (_probing) {
+      return const Row(
+        children: [
+          SizedBox(
+            width: 16,
+            height: 16,
+            child: CircularProgressIndicator(strokeWidth: 2),
+          ),
+          SizedBox(width: 10),
+          Expanded(child: Text('Checking server…')),
+        ],
+      );
+    }
+
+    if (_serverStatus case final status?) {
+      return Row(
+        children: [
+          const Icon(Icons.check_circle, color: ScribeColors.success, size: 18),
+          const SizedBox(width: 8),
+          Expanded(
+            child: Text(
+              'Server reachable · API v${status.apiVersion} · ${status.appVersion}',
+              style: const TextStyle(color: ScribeColors.success),
+            ),
+          ),
+        ],
+      );
+    }
+
+    if (_statusUnavailable) {
+      return const Row(
+        children: [
+          Icon(Icons.info_outline, color: ScribeColors.secondaryText, size: 18),
+          SizedBox(width: 8),
+          Expanded(child: Text('Server reachable · API version unavailable')),
+        ],
+      );
+    }
+
+    if (_reachabilityError case final message?) {
+      return Row(
+        children: [
+          const Icon(Icons.error_outline, color: ScribeColors.danger, size: 18),
+          const SizedBox(width: 8),
+          Expanded(
+            child: Text(
+              message,
+              style: const TextStyle(color: ScribeColors.danger),
+            ),
+          ),
+        ],
+      );
+    }
+
+    return const Text(
+      'HTTPS is tried first; HTTP is used if TLS is unavailable.',
+      style: ScribeType.metadata,
+    );
+  }
+
   @override
   Widget build(BuildContext context) {
     return Scaffold(
@@ -89,24 +245,38 @@ class _ConnectionScreenState extends State<ConnectionScreen> {
                 const SizedBox(height: ScribeSpacing.relatedGap),
                 TextField(
                   controller: _baseUrl,
+                  enabled: !_busy,
                   keyboardType: TextInputType.url,
                   autocorrect: false,
+                  onChanged: _onAddressChanged,
                   decoration: const InputDecoration(
-                    hintText: 'http://192.168.1.10:8080',
-                    labelText: 'Server URL',
+                    hintText: '192.168.1.10:8080 or server.example.com',
+                    labelText: 'Server address',
                   ),
                 ),
                 const SizedBox(height: ScribeSpacing.relatedGap),
+                _reachabilityIndicator(),
+                const SizedBox(height: ScribeSpacing.majorSectionGap),
                 const SectionLabel('API key'),
                 const SizedBox(height: ScribeSpacing.relatedGap),
                 TextField(
                   controller: _apiKey,
-                  obscureText: true,
+                  enabled: !_busy,
+                  keyboardType: TextInputType.visiblePassword,
+                  obscureText: !_showApiKey,
                   autocorrect: false,
-                  decoration: const InputDecoration(
+                  enableSuggestions: false,
+                  decoration: InputDecoration(
                     labelText: 'API key',
-                    helperText:
-                        'Stored in your device keychain. Never leaves your network.',
+                    helperText: 'Stored securely on this device.',
+                    suffixIcon: IconButton(
+                      tooltip: _showApiKey ? 'Hide API key' : 'Show API key',
+                      onPressed: () =>
+                          setState(() => _showApiKey = !_showApiKey),
+                      icon: Icon(
+                        _showApiKey ? Icons.visibility_off : Icons.visibility,
+                      ),
+                    ),
                   ),
                 ),
                 const SizedBox(height: ScribeSpacing.majorSectionGap),

+ 150 - 35
mobile/lib/screens/edit_resource_screen.dart

@@ -43,12 +43,19 @@ class _EditResourceScreenState extends State<EditResourceScreen> {
   late List<String> _tags;
   late Map<String, String> _labels;
   late List<String> _runsOn;
+  late final Map<TextEditingController, String> _initialText;
 
   String? _error;
 
   static const _systemTypes = [
-    'baremetal', 'hypervisor', 'vm', 'container',
-    'embedded', 'cloud', 'cluster', 'other',
+    'baremetal',
+    'hypervisor',
+    'vm',
+    'container',
+    'embedded',
+    'cloud',
+    'cluster',
+    'other',
   ];
 
   @override
@@ -58,18 +65,21 @@ class _EditResourceScreenState extends State<EditResourceScreen> {
     _model = TextEditingController(text: r.model ?? '');
     _description = TextEditingController(text: r.description ?? '');
     _ramSize = TextEditingController(
-        text: r.ram?.size != null ? r.ram!.size!.toStringAsFixed(0) : '');
+      text: r.ram?.size != null ? r.ram!.size!.toStringAsFixed(0) : '',
+    );
     _ramMts = TextEditingController(text: r.ram?.mts?.toString() ?? '');
     _os = TextEditingController(text: r.os ?? '');
     _cores = TextEditingController(text: r.cores?.toString() ?? '');
     _systemRam = TextEditingController(
-        text: r.ramGb != null ? r.ramGb!.toStringAsFixed(0) : '');
+      text: r.ramGb != null ? r.ramGb!.toStringAsFixed(0) : '',
+    );
     _ip = TextEditingController(text: r.ip ?? r.network?.ip ?? '');
     _port = TextEditingController(text: r.network?.port?.toString() ?? '');
     _protocol = TextEditingController(text: r.network?.protocol ?? '');
     _url = TextEditingController(text: r.network?.url ?? '');
     _speed = TextEditingController(
-        text: r.speed != null ? r.speed!.toStringAsFixed(0) : '');
+      text: r.speed != null ? r.speed!.toStringAsFixed(0) : '',
+    );
     _va = TextEditingController(text: r.va?.toString() ?? '');
     _notes = TextEditingController(text: r.notes ?? '');
     _newTag = TextEditingController();
@@ -81,14 +91,73 @@ class _EditResourceScreenState extends State<EditResourceScreen> {
     _tags = List.of(r.tags);
     _labels = Map.of(r.labels);
     _runsOn = List.of(r.runsOn);
+
+    _initialText = {
+      for (final controller in _trackedControllers) controller: controller.text,
+    };
+    for (final controller in _trackedControllers) {
+      controller.addListener(_onTrackedFieldChanged);
+    }
+  }
+
+  List<TextEditingController> get _trackedControllers => [
+    _model,
+    _description,
+    _ramSize,
+    _ramMts,
+    _os,
+    _cores,
+    _systemRam,
+    _ip,
+    _port,
+    _protocol,
+    _url,
+    _speed,
+    _va,
+    _notes,
+  ];
+
+  void _onTrackedFieldChanged() {
+    if (mounted) setState(() {});
+  }
+
+  bool get _hasUnsavedChanges {
+    final r = widget.resource;
+    return _trackedControllers.any(
+          (controller) => controller.text != _initialText[controller],
+        ) ||
+        _ipmi != r.ipmi ||
+        _managed != r.managed ||
+        _poe != r.poe ||
+        _type != r.type ||
+        !_listEquals(_tags, r.tags) ||
+        !_mapsEqual(_labels, r.labels) ||
+        !_listEquals(_runsOn, r.runsOn);
   }
 
   @override
   void dispose() {
+    for (final controller in _trackedControllers) {
+      controller.removeListener(_onTrackedFieldChanged);
+    }
     for (final controller in [
-      _model, _description, _ramSize, _ramMts, _os, _cores, _systemRam,
-      _ip, _port, _protocol, _url, _speed, _va, _notes, _newTag,
-      _newLabelKey, _newLabelValue,
+      _model,
+      _description,
+      _ramSize,
+      _ramMts,
+      _os,
+      _cores,
+      _systemRam,
+      _ip,
+      _port,
+      _protocol,
+      _url,
+      _speed,
+      _va,
+      _notes,
+      _newTag,
+      _newLabelKey,
+      _newLabelValue,
     ]) {
       controller.dispose();
     }
@@ -99,7 +168,11 @@ class _EditResourceScreenState extends State<EditResourceScreen> {
     final r = widget.resource;
     final fields = <String, Object?>{};
 
-    void maybeString(String key, TextEditingController controller, String? original) {
+    void maybeString(
+      String key,
+      TextEditingController controller,
+      String? original,
+    ) {
       final value = controller.text.trim();
       if (value.isEmpty) return;
       if (value != (original ?? '')) fields[key] = value;
@@ -111,7 +184,11 @@ class _EditResourceScreenState extends State<EditResourceScreen> {
       fields[key] = value;
     }
 
-    void maybeDouble(String key, TextEditingController controller, double? original) {
+    void maybeDouble(
+      String key,
+      TextEditingController controller,
+      double? original,
+    ) {
       final value = double.tryParse(controller.text.trim());
       if (value == null || value == original) return;
       fields[key] = value;
@@ -129,7 +206,9 @@ class _EditResourceScreenState extends State<EditResourceScreen> {
         if (portableRam != null) fields['ram'] = portableRam;
       case 'Switch' || 'Router' || 'Firewall':
         maybeString('model', _model, r.model);
-        if (_managed != r.managed && _managed != null) fields['managed'] = _managed;
+        if (_managed != r.managed && _managed != null) {
+          fields['managed'] = _managed;
+        }
         if (_poe != r.poe && _poe != null) fields['poe'] = _poe;
       case 'AccessPoint':
         maybeString('model', _model, r.model);
@@ -202,14 +281,16 @@ class _EditResourceScreenState extends State<EditResourceScreen> {
     }
 
     try {
-      await widget.store.applyPatch(resourcePatch(
-        kind: widget.resource.kind,
-        name: widget.resource.name,
-        fields: fields,
-        tags: tagsChanged ? _tags : null,
-        labels: labelsChanged ? _labels : null,
-        notes: notesChanged ? _notes.text : null,
-      ));
+      await widget.store.applyPatch(
+        resourcePatch(
+          kind: widget.resource.kind,
+          name: widget.resource.name,
+          fields: fields,
+          tags: tagsChanged ? _tags : null,
+          labels: labelsChanged ? _labels : null,
+          notes: notesChanged ? _notes.text : null,
+        ),
+      );
       if (!mounted) return;
       Navigator.of(context).pop();
     } catch (e) {
@@ -234,7 +315,26 @@ class _EditResourceScreenState extends State<EditResourceScreen> {
         backgroundColor: ScribeColors.canvas,
         elevation: 0,
         scrolledUnderElevation: 0,
-        title: Text('Edit ${r.name}'),
+        title: Column(
+          crossAxisAlignment: CrossAxisAlignment.start,
+          mainAxisAlignment: MainAxisAlignment.center,
+          children: [
+            Text(
+              'Edit ${r.name}',
+              maxLines: 1,
+              overflow: TextOverflow.ellipsis,
+            ),
+            if (_hasUnsavedChanges) ...[
+              const SizedBox(height: 2),
+              Text(
+                'Not saved',
+                style: Theme.of(
+                  context,
+                ).textTheme.labelSmall?.copyWith(color: ScribeColors.danger),
+              ),
+            ],
+          ],
+        ),
       ),
       body: ScribeSheet(
         children: [
@@ -286,9 +386,18 @@ class _EditResourceScreenState extends State<EditResourceScreen> {
   }
 
   bool get _hasScalars => const {
-        'Server', 'Desktop', 'Laptop', 'Switch', 'Router', 'Firewall',
-        'AccessPoint', 'Ups', 'Other', 'System', 'Service',
-      }.contains(widget.resource.kind);
+    'Server',
+    'Desktop',
+    'Laptop',
+    'Switch',
+    'Router',
+    'Firewall',
+    'AccessPoint',
+    'Ups',
+    'Other',
+    'System',
+    'Service',
+  }.contains(widget.resource.kind);
 
   List<Widget> _scalarInputs() {
     switch (widget.resource.kind) {
@@ -374,15 +483,13 @@ class _EditResourceScreenState extends State<EditResourceScreen> {
   }
 
   Widget _runsOnPicker() {
-    final candidates = (widget.store.document?.resources ?? [])
-        .where((c) {
-          if (c.name == widget.resource.name) return false;
-          if (widget.resource.kind == 'System') {
-            return c.isHardware || c.kind == 'System';
-          }
-          return c.kind == 'System';
-        })
-        .toList();
+    final candidates = (widget.store.document?.resources ?? []).where((c) {
+      if (c.name == widget.resource.name) return false;
+      if (widget.resource.kind == 'System') {
+        return c.isHardware || c.kind == 'System';
+      }
+      return c.kind == 'System';
+    }).toList();
 
     return Column(
       crossAxisAlignment: CrossAxisAlignment.start,
@@ -468,7 +575,12 @@ class _EditResourceScreenState extends State<EditResourceScreen> {
           for (final entry in _labels.entries)
             Row(
               children: [
-                Expanded(child: Text('${entry.key}: ${entry.value}', style: ScribeType.body)),
+                Expanded(
+                  child: Text(
+                    '${entry.key}: ${entry.value}',
+                    style: ScribeType.body,
+                  ),
+                ),
                 IconButton(
                   tooltip: 'Remove label ${entry.key}',
                   icon: const Icon(Icons.close),
@@ -523,8 +635,11 @@ class _EditResourceScreenState extends State<EditResourceScreen> {
     );
   }
 
-  Widget _field(TextEditingController controller, String label,
-      [TextInputType type = TextInputType.text]) {
+  Widget _field(
+    TextEditingController controller,
+    String label, [
+    TextInputType type = TextInputType.text,
+  ]) {
     return TextField(
       controller: controller,
       keyboardType: type,

+ 16 - 2
mobile/lib/screens/home_shell.dart

@@ -30,6 +30,16 @@ class HomeShell extends StatefulWidget {
 class _HomeShellState extends State<HomeShell> {
   late final InventoryStore _store;
   int _index = 0;
+  ResourceFilter _resourceFilter = const ResourceFilter();
+  int _filterRevision = 0;
+
+  void _openResources(ResourceFilter filter) {
+    setState(() {
+      _resourceFilter = filter;
+      _filterRevision++;
+      _index = 1;
+    });
+  }
 
   @override
   void initState() {
@@ -137,8 +147,12 @@ class _HomeShellState extends State<HomeShell> {
           body: IndexedStack(
             index: _index,
             children: [
-              SummaryTab(store: _store),
-              ResourcesTab(store: _store),
+              SummaryTab(store: _store, onOpenResources: _openResources),
+              ResourcesTab(
+                store: _store,
+                filter: _resourceFilter,
+                filterRevision: _filterRevision,
+              ),
               TreeTab(store: _store),
             ],
           ),

+ 256 - 29
mobile/lib/screens/resources_tab.dart

@@ -6,10 +6,51 @@ import '../state/inventory_store.dart';
 import 'add_resource_screen.dart';
 import 'resource_detail_screen.dart';
 
+class ResourceFilter {
+  const ResourceFilter({
+    this.kind,
+    this.hardwareOnly = false,
+    this.systemType,
+    this.systemOs,
+    this.hasIpAddress = false,
+    this.tag,
+    this.labelKey,
+  });
+
+  final String? kind;
+  final bool hardwareOnly;
+  final String? systemType;
+  final String? systemOs;
+  final bool hasIpAddress;
+  final String? tag;
+  final String? labelKey;
+}
+
+const _resourceKinds = [
+  'Server',
+  'Switch',
+  'Router',
+  'Firewall',
+  'AccessPoint',
+  'Desktop',
+  'Laptop',
+  'Ups',
+  'Other',
+  'System',
+  'Service',
+];
+
 class ResourcesTab extends StatefulWidget {
-  const ResourcesTab({super.key, required this.store});
+  const ResourcesTab({
+    super.key,
+    required this.store,
+    required this.filter,
+    required this.filterRevision,
+  });
 
   final InventoryStore store;
+  final ResourceFilter filter;
+  final int filterRevision;
 
   @override
   State<ResourcesTab> createState() => _ResourcesTabState();
@@ -18,31 +59,125 @@ class ResourcesTab extends StatefulWidget {
 class _ResourcesTabState extends State<ResourcesTab> {
   String _query = '';
   String? _kind;
+  bool _hardwareOnly = false;
+  String? _systemType;
+  String? _systemOs;
+  bool _hasIpAddress = false;
+  String? _tag;
+  String? _labelKey;
+  late final TextEditingController _searchController;
+
+  static const _kinds = _resourceKinds;
+
+  @override
+  void initState() {
+    super.initState();
+    _searchController = TextEditingController();
+    _applyFilter(widget.filter);
+  }
+
+  @override
+  void didUpdateWidget(covariant ResourcesTab oldWidget) {
+    super.didUpdateWidget(oldWidget);
+    if (oldWidget.filterRevision != widget.filterRevision) {
+      _applyFilter(widget.filter);
+    }
+  }
+
+  @override
+  void dispose() {
+    _searchController.dispose();
+    super.dispose();
+  }
+
+  void _applyFilter(ResourceFilter filter) {
+    _kind = filter.kind;
+    _hardwareOnly = filter.hardwareOnly;
+    _systemType = filter.systemType;
+    _systemOs = filter.systemOs;
+    _hasIpAddress = filter.hasIpAddress;
+    _tag = filter.tag;
+    _labelKey = filter.labelKey;
+    _query = '';
+    _searchController.clear();
+  }
 
-  static const _kinds = [
-    'Server', 'Switch', 'Router', 'Firewall', 'AccessPoint', 'Desktop',
-    'Laptop', 'Ups', 'Other', 'System', 'Service',
-  ];
+  bool get _hasFilters =>
+      _kind != null ||
+      _hardwareOnly ||
+      _systemType != null ||
+      _systemOs != null ||
+      _hasIpAddress ||
+      _tag != null ||
+      _labelKey != null ||
+      _query.isNotEmpty;
 
   List<Resource> get _visible {
     final all = widget.store.document?.resources ?? [];
     final query = _query.trim().toLowerCase();
     return all.where((r) {
       if (_kind != null && r.kind != _kind) return false;
+      if (_hardwareOnly && !r.isHardware) return false;
+      if (_systemType != null && r.type != _systemType) return false;
+      if (_systemOs != null && r.os != _systemOs) return false;
+      if (_hasIpAddress && !_hasResolvedIp(r, all)) return false;
+      if (_tag != null && !r.tags.contains(_tag)) return false;
+      if (_labelKey != null && !r.labels.containsKey(_labelKey)) return false;
       if (query.isEmpty) return true;
       if (r.name.toLowerCase().contains(query)) return true;
       if (r.tags.any((t) => t.toLowerCase().contains(query))) return true;
-      if (r.labels.entries
-          .any((e) => '${e.key}: ${e.value}'.toLowerCase().contains(query))) {
+      if (r.labels.entries.any(
+        (e) => '${e.key}: ${e.value}'.toLowerCase().contains(query),
+      )) {
         return true;
       }
       final ip = r.ip ?? r.network?.ip;
       return ip != null && ip.toLowerCase().contains(query);
-    }).toList()
-      ..sort((a, b) {
-        final byKind = a.kind.compareTo(b.kind);
-        return byKind != 0 ? byKind : a.name.compareTo(b.name);
-      });
+    }).toList()..sort((a, b) {
+      final byKind = a.kind.compareTo(b.kind);
+      return byKind != 0 ? byKind : a.name.compareTo(b.name);
+    });
+  }
+
+  bool _hasResolvedIp(Resource resource, List<Resource> all) {
+    final directIp = resource.ip ?? resource.network?.ip;
+    if (directIp != null && directIp.trim().isNotEmpty) return true;
+    if (resource.kind != 'Service' || resource.runsOn.length != 1) return false;
+
+    Resource? parent;
+    for (final item in all) {
+      if (item.name == resource.runsOn.single) {
+        parent = item;
+        break;
+      }
+    }
+    if (parent == null || parent.kind != 'System') return false;
+    return _hasResolvedIp(parent, all);
+  }
+
+  List<String> get _systemOperatingSystems {
+    final values =
+        (widget.store.document?.resources ?? [])
+            .where((resource) => resource.kind == 'System')
+            .map((resource) => resource.os)
+            .whereType<String>()
+            .where((os) => os.trim().isNotEmpty)
+            .toSet()
+            .toList()
+          ..sort();
+    return values;
+  }
+
+  List<String> get _systemTypes {
+    final values = (widget.store.document?.resources ?? [])
+        .where((resource) => resource.kind == 'System')
+        .map((resource) => resource.type)
+        .whereType<String>()
+        .where((type) => type.trim().isNotEmpty)
+        .toSet()
+        .toList()
+      ..sort();
+    return values;
   }
 
   @override
@@ -64,9 +199,11 @@ class _ResourcesTabState extends State<ResourcesTab> {
       floatingActionButton: FloatingActionButton.extended(
         heroTag: 'add-resource',
         backgroundColor: ScribeColors.accent,
-        onPressed: () => Navigator.of(context).push(MaterialPageRoute(
-          builder: (_) => AddResourceScreen(store: widget.store),
-        )),
+        onPressed: () => Navigator.of(context).push(
+          MaterialPageRoute(
+            builder: (_) => AddResourceScreen(store: widget.store),
+          ),
+        ),
         icon: const Icon(Icons.add),
         label: const Text('Add'),
       ),
@@ -75,6 +212,7 @@ class _ResourcesTabState extends State<ResourcesTab> {
         child: ScribeSheet(
           children: [
             TextField(
+              controller: _searchController,
               decoration: const InputDecoration(
                 hintText: 'Search names, IPs, tags, labels',
                 prefixIcon: Icon(Icons.search),
@@ -89,24 +227,93 @@ class _ResourcesTabState extends State<ResourcesTab> {
                 children: [
                   FilterPill(
                     label: 'All',
-                    selected: _kind == null,
-                    onTap: () => setState(() => _kind = null),
+                    selected: !_hasFilters,
+                    onTap: () => setState(() {
+                      _applyFilter(const ResourceFilter());
+                    }),
+                  ),
+                  const SizedBox(width: 8),
+                  FilterPill(
+                    label: 'Hardware',
+                    selected: _hardwareOnly,
+                    onTap: () => setState(() {
+                      _applyFilter(const ResourceFilter(hardwareOnly: true));
+                    }),
                   ),
                   for (final kind in _kinds) ...[
                     const SizedBox(width: 8),
                     FilterPill(
                       label: _plurals[kind] ?? kind,
                       selected: _kind == kind,
-                      onTap: () => setState(() => _kind = kind),
+                      onTap: () => setState(() {
+                        _kind = kind;
+                        _hardwareOnly = false;
+                        _systemType = null;
+                        _systemOs = null;
+                      }),
                     ),
                   ],
                 ],
               ),
             ),
-            SurfacePanel(
-              padding: EdgeInsets.zero,
-              child: _list(context),
+            if (_kind == 'System') ...[
+              const SizedBox(height: ScribeSpacing.relatedGap),
+              SizedBox(
+                height: ScribeSpacing.minTarget,
+                child: ListView(
+                  scrollDirection: Axis.horizontal,
+                  padding: EdgeInsets.zero,
+                  children: [
+                    for (final type in _systemTypes) ...[
+                      FilterPill(
+                        label: 'Type: $type',
+                        selected: _systemType == type,
+                        onTap: () => setState(() {
+                          _systemType = _systemType == type ? null : type;
+                        }),
+                      ),
+                      const SizedBox(width: 8),
+                    ],
+                    for (final os in _systemOperatingSystems) ...[
+                      FilterPill(
+                        label: 'OS: $os',
+                        selected: _systemOs == os,
+                        onTap: () => setState(() {
+                          _systemOs = _systemOs == os ? null : os;
+                        }),
+                      ),
+                      const SizedBox(width: 8),
+                    ],
+                  ],
+                ),
+              ),
+            ],
+            const SizedBox(height: ScribeSpacing.relatedGap),
+            Wrap(
+              spacing: 8,
+              children: [
+                FilterPill(
+                  label: 'Has IP',
+                  selected: _hasIpAddress,
+                  onTap: () => setState(() {
+                    _hasIpAddress = !_hasIpAddress;
+                  }),
+                ),
+                if (_tag != null)
+                  FilterPill(
+                    label: 'Tag: $_tag',
+                    selected: true,
+                    onTap: () => setState(() => _tag = null),
+                  ),
+                if (_labelKey != null)
+                  FilterPill(
+                    label: 'Label: $_labelKey',
+                    selected: true,
+                    onTap: () => setState(() => _labelKey = null),
+                  ),
+              ],
             ),
+            SurfacePanel(padding: EdgeInsets.zero, child: _list(context)),
           ],
         ),
       ),
@@ -126,19 +333,31 @@ class _ResourcesTabState extends State<ResourcesTab> {
       children: [
         for (var i = 0; i < visible.length; i++) ...[
           if (i > 0)
-            const Divider(height: 1, thickness: 1, color: ScribeColors.separator),
+            const Divider(
+              height: 1,
+              thickness: 1,
+              color: ScribeColors.separator,
+            ),
           ListTile(
             contentPadding: const EdgeInsets.symmetric(
-                horizontal: ScribeSpacing.panelPadding, vertical: 4),
+              horizontal: ScribeSpacing.panelPadding,
+              vertical: 4,
+            ),
             leading: CategoryMark(icon: visible[i].glyph),
             title: Text(visible[i].name, style: ScribeType.title),
             subtitle: Text(visible[i].subtitle, style: ScribeType.metadata),
-            trailing: const Icon(Icons.chevron_right,
-                color: ScribeColors.tertiaryText),
-            onTap: () => Navigator.of(context).push(MaterialPageRoute(
-              builder: (_) =>
-                  ResourceDetailScreen(store: widget.store, name: visible[i].name),
-            )),
+            trailing: const Icon(
+              Icons.chevron_right,
+              color: ScribeColors.tertiaryText,
+            ),
+            onTap: () => Navigator.of(context).push(
+              MaterialPageRoute(
+                builder: (_) => ResourceDetailScreen(
+                  store: widget.store,
+                  name: visible[i].name,
+                ),
+              ),
+            ),
           ),
         ],
       ],
@@ -159,3 +378,11 @@ const _plurals = {
   'System': 'Systems',
   'Service': 'Services',
 };
+
+String resourceKindForSummary(String kind) => _resourceKinds.firstWhere(
+  (candidate) => candidate.toLowerCase() == kind.toLowerCase(),
+  orElse: () => kind,
+);
+
+String resourcePluralForSummary(String kind) =>
+    _plurals[resourceKindForSummary(kind)] ?? kind;

+ 157 - 35
mobile/lib/screens/summary_tab.dart

@@ -2,11 +2,17 @@ import 'package:flutter/material.dart';
 
 import '../design.dart';
 import '../state/inventory_store.dart';
+import 'resources_tab.dart';
 
 class SummaryTab extends StatelessWidget {
-  const SummaryTab({super.key, required this.store});
+  const SummaryTab({
+    super.key,
+    required this.store,
+    required this.onOpenResources,
+  });
 
   final InventoryStore store;
+  final ValueChanged<ResourceFilter> onOpenResources;
 
   @override
   Widget build(BuildContext context) {
@@ -25,28 +31,66 @@ class SummaryTab extends StatelessWidget {
     final summary = store.summary;
     if (summary == null) return const ScribeSpinner();
 
+    final hardware = summary['hardware'] as Map<String, dynamic>;
+    final systems = summary['systems'] as Map<String, dynamic>;
+    final services = summary['services'] as Map<String, dynamic>;
+
     return RefreshIndicator(
       onRefresh: store.refresh,
       child: ScribeSheet(
         children: [
           const Text('Infrastructure', style: ScribeType.hero),
-          _countPanel('Hardware', summary['hardware'] as Map<String, dynamic>,
-              'totalHardware', 'hardwareByKind'),
-          _countPanel('Systems', summary['systems'] as Map<String, dynamic>,
-              'totalSystems', 'systemsByType'),
-          _servicesPanel(summary['services'] as Map<String, dynamic>),
-          _tagPanel('Tags', summary['tags'] as Map<String, dynamic>? ?? {}),
-          _tagPanel('Labels', summary['labels'] as Map<String, dynamic>? ?? {}),
+          _countPanel(
+            'Hardware',
+            hardware,
+            'totalHardware',
+            'hardwareByKind',
+            totalFilter: const ResourceFilter(hardwareOnly: true),
+            itemFilter: (kind) =>
+                ResourceFilter(kind: resourceKindForSummary(kind)),
+          ),
+          _countPanel(
+            'Systems',
+            systems,
+            'totalSystems',
+            'systemsByType',
+            totalFilter: const ResourceFilter(kind: 'System'),
+            itemFilter: (type) =>
+                ResourceFilter(kind: 'System', systemType: type),
+          ),
+          _breakdownPanel(
+            'Operating systems',
+            systems['systemsByOs'] as Map<String, dynamic>? ?? {},
+            (os) => ResourceFilter(kind: 'System', systemOs: os),
+          ),
+          _servicesPanel(services),
+          _tagPanel(
+            'Tags',
+            summary['tags'] as Map<String, dynamic>? ?? {},
+            (tag) => ResourceFilter(tag: tag),
+          ),
+          _tagPanel(
+            'Labels',
+            summary['labels'] as Map<String, dynamic>? ?? {},
+            (key) => ResourceFilter(labelKey: key),
+          ),
         ],
       ),
     );
   }
 
-  Widget _countPanel(String title, Map<String, dynamic> section, String totalKey,
-      String byKey) {
+  Widget _countPanel(
+    String title,
+    Map<String, dynamic> section,
+    String totalKey,
+    String byKey, {
+    required ResourceFilter totalFilter,
+    required ResourceFilter Function(String key) itemFilter,
+  }) {
     final total = section[totalKey] as int? ?? 0;
-    final breakdown = (section[byKey] as Map<String, dynamic>? ?? {})
-        .map((k, v) => MapEntry(k, (v as num).toInt()));
+    final breakdown = (section[byKey] as Map<String, dynamic>? ?? {}).map(
+      (k, v) => MapEntry(k, (v as num).toInt()),
+    );
 
     return SurfacePanel(
       child: Column(
@@ -54,20 +98,69 @@ class SummaryTab extends StatelessWidget {
         children: [
           SectionLabel(title),
           const SizedBox(height: ScribeSpacing.relatedGap),
-          Row(
-            mainAxisAlignment: MainAxisAlignment.spaceBetween,
-            children: [
-              Text('$total', style: ScribeType.section),
-              Text('$total total', style: ScribeType.metadata),
-            ],
+          InkWell(
+            borderRadius: BorderRadius.circular(ScribeSpacing.controlRadius),
+            onTap: () => onOpenResources(totalFilter),
+            child: Padding(
+              padding: const EdgeInsets.symmetric(vertical: 4),
+              child: Row(
+                mainAxisAlignment: MainAxisAlignment.spaceBetween,
+                children: [
+                  Text('$total', style: ScribeType.section),
+                  Row(
+                    children: [
+                      Text('$total total', style: ScribeType.metadata),
+                      const SizedBox(width: 4),
+                      const Icon(Icons.chevron_right, size: 18),
+                    ],
+                  ),
+                ],
+              ),
+            ),
           ),
+          if (breakdown.isNotEmpty) ...[
+            const SizedBox(height: ScribeSpacing.relatedGap),
+            Wrap(
+              spacing: 8,
+              runSpacing: 8,
+              children: [
+                for (final entry in breakdown.entries)
+                  FilterPill(
+                    label:
+                        '${entry.value} ${resourcePluralForSummary(entry.key)}',
+                    selected: false,
+                    onTap: () => onOpenResources(itemFilter(entry.key)),
+                  ),
+              ],
+            ),
+          ],
+        ],
+      ),
+    );
+  }
+
+  Widget _breakdownPanel(
+    String title,
+    Map<String, dynamic> counts,
+    ResourceFilter Function(String key) filterFor,
+  ) {
+    if (counts.isEmpty) return const SizedBox.shrink();
+    return SurfacePanel(
+      child: Column(
+        crossAxisAlignment: CrossAxisAlignment.start,
+        children: [
+          SectionLabel(title),
           const SizedBox(height: ScribeSpacing.relatedGap),
           Wrap(
-            spacing: ScribeSpacing.relatedGap,
+            spacing: 8,
             runSpacing: 8,
             children: [
-              for (final entry in breakdown.entries)
-                NoteFact(text: '${entry.value} ${entry.key}'),
+              for (final entry in counts.entries)
+                FilterPill(
+                  label: '${entry.value} ${entry.key}',
+                  selected: false,
+                  onTap: () => onOpenResources(filterFor(entry.key)),
+                ),
             ],
           ),
         ],
@@ -76,33 +169,58 @@ class SummaryTab extends StatelessWidget {
   }
 
   Widget _servicesPanel(Map<String, dynamic> services) {
+    final total = services['totalServices'] as int? ?? 0;
+    final totalIpAddresses = services['totalIpAddresses'] as int? ?? 0;
     return SurfacePanel(
       child: Row(
         children: [
           Expanded(
-            child: Column(
-              crossAxisAlignment: CrossAxisAlignment.start,
-              children: [
-                Text('${services['totalServices'] ?? 0}', style: ScribeType.section),
-                const Text('services', style: ScribeType.metadata),
-              ],
+            child: _serviceCount(
+              total,
+              'services',
+              const ResourceFilter(kind: 'Service'),
             ),
           ),
           Expanded(
-            child: Column(
+            child: _serviceCount(
+              totalIpAddresses,
+              'IP addresses',
+              const ResourceFilter(kind: 'Service', hasIpAddress: true),
+            ),
+          ),
+        ],
+      ),
+    );
+  }
+
+  Widget _serviceCount(int count, String label, ResourceFilter filter) {
+    return InkWell(
+      borderRadius: BorderRadius.circular(ScribeSpacing.controlRadius),
+      onTap: () => onOpenResources(filter),
+      child: Padding(
+        padding: const EdgeInsets.symmetric(vertical: 4),
+        child: Row(
+          children: [
+            Column(
               crossAxisAlignment: CrossAxisAlignment.start,
               children: [
-                Text('${services['totalIpAddresses'] ?? 0}', style: ScribeType.section),
-                const Text('IP addresses', style: ScribeType.metadata),
+                Text('$count', style: ScribeType.section),
+                Text(label, style: ScribeType.metadata),
               ],
             ),
-          ),
-        ],
+            const SizedBox(width: 4),
+            const Icon(Icons.chevron_right, size: 18),
+          ],
+        ),
       ),
     );
   }
 
-  Widget _tagPanel(String title, Map<String, dynamic> counts) {
+  Widget _tagPanel(
+    String title,
+    Map<String, dynamic> counts,
+    ResourceFilter Function(String key) filterFor,
+  ) {
     if (counts.isEmpty) return const SizedBox.shrink();
     return SurfacePanel(
       child: Column(
@@ -111,11 +229,15 @@ class SummaryTab extends StatelessWidget {
           SectionLabel(title),
           const SizedBox(height: ScribeSpacing.relatedGap),
           Wrap(
-            spacing: ScribeSpacing.relatedGap,
+            spacing: 8,
             runSpacing: 8,
             children: [
               for (final entry in counts.entries)
-                NoteFact(text: '${entry.key} (${entry.value})'),
+                FilterPill(
+                  label: '${entry.key} (${entry.value})',
+                  selected: false,
+                  onTap: () => onOpenResources(filterFor(entry.key)),
+                ),
             ],
           ),
         ],

+ 1 - 1
mobile/pubspec.yaml

@@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
 # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
 # In Windows, build-name is used as the major, minor, and patch parts
 # of the product and file versions while build-number is used as the build suffix.
-version: 1.0.0+1
+version: 1.0.0+2
 
 environment:
   sdk: ^3.11.1

+ 52 - 0
mobile/test/api_client_test.dart

@@ -0,0 +1,52 @@
+import 'dart:convert';
+
+import 'package:flutter_test/flutter_test.dart';
+import 'package:http/http.dart' as http;
+import 'package:http/testing.dart';
+import 'package:rackpeek_mobile/data/api_client.dart';
+
+void main() {
+  group('serverUrlCandidates', () {
+    test('infers HTTPS first and HTTP as a fallback', () {
+      expect(serverUrlCandidates('192.168.1.10:8080'), [
+        'https://192.168.1.10:8080',
+        'http://192.168.1.10:8080',
+      ]);
+    });
+
+    test('preserves an explicitly supplied scheme', () {
+      expect(serverUrlCandidates('http://server.local:8080'), [
+        'http://server.local:8080',
+      ]);
+    });
+
+    test('rejects malformed or non-HTTP addresses', () {
+      expect(serverUrlCandidates(''), isEmpty);
+      expect(serverUrlCandidates('ftp://server.local'), isEmpty);
+      expect(serverUrlCandidates('https://'), isEmpty);
+    });
+  });
+
+  test(
+    'status request reads API and app versions without sending an API key',
+    () async {
+      final client = MockClient((request) async {
+        expect(request.url.path, '/api/status');
+        expect(request.headers.containsKey('X-Api-Key'), isFalse);
+        return http.Response(
+          jsonEncode({'apiVersion': '1', 'appVersion': 'v2.2.0'}),
+          200,
+        );
+      });
+
+      final status = await RackPeekApi(
+        baseUrl: 'https://server.example.test',
+        apiKey: '',
+        client: client,
+      ).getStatus();
+
+      expect(status.apiVersion, '1');
+      expect(status.appVersion, 'v2.2.0');
+    },
+  );
+}

+ 23 - 0
mobile/test/connection_screen_test.dart

@@ -0,0 +1,23 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:rackpeek_mobile/data/settings_store.dart';
+import 'package:rackpeek_mobile/screens/connection_screen.dart';
+
+void main() {
+  testWidgets('API key field can be shown and hidden', (tester) async {
+    await tester.pumpWidget(
+      MaterialApp(home: ConnectionScreen(settings: SettingsStore())),
+    );
+
+    final apiKeyField = find.byType(TextField).at(1);
+    expect(tester.widget<TextField>(apiKeyField).obscureText, isTrue);
+
+    await tester.tap(find.byTooltip('Show API key'));
+    await tester.pump();
+    expect(tester.widget<TextField>(apiKeyField).obscureText, isFalse);
+
+    await tester.tap(find.byTooltip('Hide API key'));
+    await tester.pump();
+    expect(tester.widget<TextField>(apiKeyField).obscureText, isTrue);
+  });
+}

+ 39 - 0
mobile/test/edit_resource_screen_test.dart

@@ -0,0 +1,39 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:rackpeek_mobile/data/api_client.dart';
+import 'package:rackpeek_mobile/design.dart';
+import 'package:rackpeek_mobile/models/inventory.dart';
+import 'package:rackpeek_mobile/screens/edit_resource_screen.dart';
+import 'package:rackpeek_mobile/state/inventory_store.dart';
+
+void main() {
+  testWidgets('edit title indicates pending changes and clears when reverted', (
+    tester,
+  ) async {
+    final resource = Resource(kind: 'Other', name: 'lab-device', model: 'Old');
+    final store = InventoryStore(
+      RackPeekApi(baseUrl: 'https://server.example.test', apiKey: 'test-key'),
+    );
+
+    await tester.pumpWidget(
+      MaterialApp(
+        theme: scribeTheme(),
+        home: Scaffold(
+          body: EditResourceScreen(store: store, resource: resource),
+        ),
+      ),
+    );
+
+    expect(find.text('Not saved'), findsNothing);
+    final modelField = find.byType(TextField).first;
+    await tester.enterText(modelField, 'New model');
+    await tester.pump();
+    expect(find.text('Not saved'), findsOneWidget);
+
+    await tester.enterText(modelField, 'Old');
+    await tester.pump();
+    expect(find.text('Not saved'), findsNothing);
+
+    store.dispose();
+  });
+}

+ 51 - 0
mobile/test/summary_test.dart

@@ -0,0 +1,51 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:rackpeek_mobile/data/api_client.dart';
+import 'package:rackpeek_mobile/design.dart';
+import 'package:rackpeek_mobile/screens/resources_tab.dart';
+import 'package:rackpeek_mobile/screens/summary_tab.dart';
+import 'package:rackpeek_mobile/state/inventory_store.dart';
+
+void main() {
+  testWidgets('hardware summary item opens its resource kind filter', (
+    tester,
+  ) async {
+    final store =
+        InventoryStore(
+            RackPeekApi(
+              baseUrl: 'https://server.example.test',
+              apiKey: 'test-key',
+            ),
+          )
+          ..summary = {
+            'hardware': {
+              'totalHardware': 1,
+              'hardwareByKind': {'Server': 1},
+            },
+            'systems': {
+              'totalSystems': 0,
+              'systemsByType': <String, int>{},
+              'systemsByOs': <String, int>{},
+            },
+            'services': {'totalServices': 0, 'totalIpAddresses': 0},
+            'tags': <String, int>{},
+            'labels': <String, int>{},
+          };
+    ResourceFilter? selectedFilter;
+
+    await tester.pumpWidget(
+      MaterialApp(
+        theme: scribeTheme(),
+        home: Scaffold(
+          body: SummaryTab(
+            store: store,
+            onOpenResources: (filter) => selectedFilter = filter,
+          ),
+        ),
+      ),
+    );
+
+    await tester.tap(find.text('1 Servers'));
+    expect(selectedFilter?.kind, 'Server');
+  });
+}

+ 11 - 0
site/README.md

@@ -0,0 +1,11 @@
+# RackPeek website
+
+This is a small, static marketing site with a landing page, privacy policy and
+support page. It has no build step or package installation.
+
+Open `index.html` directly or serve the `site/` directory with any static web
+server. Tailwind's browser build is included locally at `assets/tailwind.js`;
+the site does not load CSS frameworks, fonts, scripts or images from a CDN.
+
+The privacy page follows the iOS app policy in `../mobile/privacy-policy.md`.
+When app data handling changes, update both copies before publishing.

BIN
site/assets/app-overview.jpg


+ 121 - 0
site/assets/rackpeek-logo.svg

@@ -0,0 +1,121 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<svg version="1.1" width="32" height="32" xmlns="http://www.w3.org/2000/svg" shape-rendering="crispEdges">
+<rect x="11" y="6" width="5" height="1" fill="#222034" />
+<rect x="9" y="7" width="2" height="1" fill="#222034" />
+<rect x="11" y="7" width="5" height="1" fill="#CBDBFC" />
+<rect x="16" y="7" width="2" height="1" fill="#222034" />
+<rect x="7" y="8" width="2" height="1" fill="#222034" />
+<rect x="9" y="8" width="2" height="1" fill="#CBDBFC" />
+<rect x="11" y="8" width="5" height="1" fill="#FFFFFF" />
+<rect x="16" y="8" width="2" height="1" fill="#CBDBFC" />
+<rect x="18" y="8" width="2" height="1" fill="#222034" />
+<rect x="6" y="9" width="1" height="2" fill="#222034" />
+<rect x="7" y="9" width="2" height="1" fill="#CBDBFC" />
+<rect x="9" y="9" width="2" height="1" fill="#FFFFFF" />
+<rect x="11" y="9" width="5" height="1" fill="#5B6EE1" />
+<rect x="16" y="9" width="2" height="1" fill="#FFFFFF" />
+<rect x="18" y="9" width="2" height="1" fill="#CBDBFC" />
+<rect x="20" y="9" width="1" height="2" fill="#222034" />
+<rect x="7" y="10" width="1" height="1" fill="#CBDBFC" />
+<rect x="8" y="10" width="1" height="1" fill="#FFFFFF" />
+<rect x="9" y="10" width="9" height="1" fill="#3F3F74" />
+<rect x="18" y="10" width="1" height="1" fill="#FFFFFF" />
+<rect x="19" y="10" width="1" height="1" fill="#CBDBFC" />
+<rect x="5" y="11" width="1" height="2" fill="#222034" />
+<rect x="6" y="11" width="1" height="2" fill="#CBDBFC" />
+<rect x="7" y="11" width="1" height="2" fill="#FFFFFF" />
+<rect x="8" y="11" width="11" height="1" fill="#222034" />
+<rect x="19" y="11" width="1" height="2" fill="#FFFFFF" />
+<rect x="20" y="11" width="1" height="2" fill="#CBDBFC" />
+<rect x="21" y="11" width="1" height="2" fill="#222034" />
+<rect x="8" y="12" width="1" height="2" fill="#222034" />
+<rect x="9" y="12" width="1" height="1" fill="#6ABE30" />
+<rect x="10" y="12" width="1" height="2" fill="#222034" />
+<rect x="11" y="12" width="1" height="1" fill="#99E550" />
+<rect x="12" y="12" width="1" height="2" fill="#222034" />
+<rect x="13" y="12" width="1" height="1" fill="#6ABE30" />
+<rect x="14" y="12" width="2" height="2" fill="#222034" />
+<rect x="16" y="12" width="1" height="1" fill="#DF7126" />
+<rect x="17" y="12" width="2" height="2" fill="#222034" />
+<rect x="4" y="13" width="1" height="4" fill="#222034" />
+<rect x="5" y="13" width="1" height="4" fill="#CBDBFC" />
+<rect x="6" y="13" width="1" height="4" fill="#FFFFFF" />
+<rect x="7" y="13" width="1" height="1" fill="#222034" />
+<rect x="9" y="13" width="1" height="1" fill="#222034" />
+<rect x="11" y="13" width="1" height="1" fill="#222034" />
+<rect x="13" y="13" width="1" height="1" fill="#222034" />
+<rect x="16" y="13" width="1" height="1" fill="#222034" />
+<rect x="19" y="13" width="1" height="1" fill="#222034" />
+<rect x="20" y="13" width="1" height="4" fill="#FFFFFF" />
+<rect x="21" y="13" width="1" height="4" fill="#CBDBFC" />
+<rect x="22" y="13" width="1" height="4" fill="#222034" />
+<rect x="7" y="14" width="13" height="1" fill="#5B6EE1" />
+<rect x="7" y="15" width="13" height="1" fill="#3F3F74" />
+<rect x="7" y="16" width="13" height="1" fill="#222034" />
+<rect x="5" y="17" width="1" height="2" fill="#222034" />
+<rect x="6" y="17" width="1" height="2" fill="#CBDBFC" />
+<rect x="7" y="17" width="1" height="2" fill="#FFFFFF" />
+<rect x="8" y="17" width="1" height="2" fill="#222034" />
+<rect x="9" y="17" width="1" height="1" fill="#99E550" />
+<rect x="10" y="17" width="1" height="2" fill="#222034" />
+<rect x="11" y="17" width="1" height="1" fill="#6ABE30" />
+<rect x="12" y="17" width="1" height="2" fill="#222034" />
+<rect x="13" y="17" width="1" height="1" fill="#99E550" />
+<rect x="14" y="17" width="2" height="2" fill="#222034" />
+<rect x="16" y="17" width="1" height="1" fill="#DF7126" />
+<rect x="17" y="17" width="2" height="2" fill="#222034" />
+<rect x="19" y="17" width="1" height="2" fill="#FFFFFF" />
+<rect x="20" y="17" width="1" height="2" fill="#CBDBFC" />
+<rect x="21" y="17" width="1" height="2" fill="#222034" />
+<rect x="9" y="18" width="1" height="1" fill="#222034" />
+<rect x="11" y="18" width="1" height="1" fill="#222034" />
+<rect x="13" y="18" width="1" height="1" fill="#222034" />
+<rect x="16" y="18" width="1" height="1" fill="#222034" />
+<rect x="6" y="19" width="1" height="2" fill="#222034" />
+<rect x="7" y="19" width="1" height="2" fill="#CBDBFC" />
+<rect x="8" y="19" width="1" height="1" fill="#FFFFFF" />
+<rect x="9" y="19" width="9" height="1" fill="#5B6EE1" />
+<rect x="18" y="19" width="1" height="1" fill="#FFFFFF" />
+<rect x="19" y="19" width="1" height="1" fill="#CBDBFC" />
+<rect x="20" y="19" width="1" height="5" fill="#8F563B" />
+<rect x="21" y="19" width="1" height="1" fill="#663931" />
+<rect x="22" y="19" width="1" height="1" fill="#222034" />
+<rect x="8" y="20" width="1" height="1" fill="#CBDBFC" />
+<rect x="9" y="20" width="2" height="1" fill="#FFFFFF" />
+<rect x="11" y="20" width="5" height="1" fill="#3F3F74" />
+<rect x="16" y="20" width="2" height="1" fill="#FFFFFF" />
+<rect x="18" y="20" width="1" height="1" fill="#CBDBFC" />
+<rect x="19" y="20" width="1" height="3" fill="#8F563B" />
+<rect x="21" y="20" width="1" height="5" fill="#8F563B" />
+<rect x="22" y="20" width="1" height="1" fill="#663931" />
+<rect x="23" y="20" width="1" height="1" fill="#222034" />
+<rect x="7" y="21" width="2" height="1" fill="#222034" />
+<rect x="9" y="21" width="2" height="1" fill="#CBDBFC" />
+<rect x="11" y="21" width="5" height="1" fill="#FFFFFF" />
+<rect x="16" y="21" width="2" height="1" fill="#CBDBFC" />
+<rect x="18" y="21" width="1" height="1" fill="#8F563B" />
+<rect x="22" y="21" width="1" height="5" fill="#8F563B" />
+<rect x="23" y="21" width="1" height="1" fill="#663931" />
+<rect x="24" y="21" width="1" height="1" fill="#222034" />
+<rect x="9" y="22" width="2" height="1" fill="#222034" />
+<rect x="11" y="22" width="5" height="1" fill="#CBDBFC" />
+<rect x="16" y="22" width="2" height="1" fill="#222034" />
+<rect x="18" y="22" width="1" height="1" fill="#663931" />
+<rect x="23" y="22" width="1" height="4" fill="#8F563B" />
+<rect x="24" y="22" width="1" height="1" fill="#663931" />
+<rect x="25" y="22" width="1" height="1" fill="#222034" />
+<rect x="11" y="23" width="5" height="1" fill="#222034" />
+<rect x="18" y="23" width="1" height="1" fill="#222034" />
+<rect x="19" y="23" width="1" height="1" fill="#663931" />
+<rect x="24" y="23" width="1" height="3" fill="#8F563B" />
+<rect x="25" y="23" width="1" height="3" fill="#663931" />
+<rect x="26" y="23" width="1" height="3" fill="#222034" />
+<rect x="19" y="24" width="1" height="1" fill="#222034" />
+<rect x="20" y="24" width="1" height="1" fill="#663931" />
+<rect x="20" y="25" width="1" height="1" fill="#222034" />
+<rect x="21" y="25" width="1" height="1" fill="#663931" />
+<rect x="21" y="26" width="1" height="1" fill="#222034" />
+<rect x="22" y="26" width="3" height="1" fill="#663931" />
+<rect x="25" y="26" width="1" height="1" fill="#222034" />
+<rect x="22" y="27" width="3" height="1" fill="#222034" />
+</svg>

+ 122 - 0
site/assets/site.css

@@ -0,0 +1,122 @@
+:root {
+  color-scheme: light;
+  --ink: #17182c;
+  --muted: #666a7d;
+  --purple: #6554d9;
+  --purple-dark: #5042c0;
+  --line: #e7e8ef;
+  --paper: #f7f8fc;
+}
+
+* { box-sizing: border-box; }
+html { scroll-behavior: smooth; }
+body { margin: 0; font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
+a { color: inherit; text-decoration: none; }
+.site-header { position: relative; z-index: 5; border-bottom: 1px solid rgba(226, 228, 238, .8); background: rgba(255,255,255,.72); }
+.brand { color: var(--ink); font-size: 1.12rem; font-weight: 750; letter-spacing: -.035em; }
+.brand img { image-rendering: pixelated; }
+.nav-links { display: flex; align-items: center; gap: 2rem; color: #54586b; font-size: .88rem; font-weight: 550; }
+.nav-links a:hover, .footer-links a:hover { color: var(--purple); }
+.nav-cta { padding: .72rem 1rem; border-radius: 999px; background: var(--ink); color: white !important; }
+.nav-cta:hover { background: var(--purple); }
+.menu-toggle { display: none; }
+.hero { min-height: 630px; }
+.eyebrow { margin: 0 0 1rem; color: var(--purple); font-size: .75rem; font-weight: 750; letter-spacing: .13em; text-transform: uppercase; }
+.status-dot, .live-dot { display: inline-block; width: .48rem; height: .48rem; margin-right: .45rem; border-radius: 50%; background: #31b88a; box-shadow: 0 0 0 4px rgba(49,184,138,.12); vertical-align: 1px; }
+.hero h1 { margin: 0; color: var(--ink); font-size: clamp(3.4rem, 6.8vw, 5.65rem); font-weight: 780; letter-spacing: -.075em; line-height: .99; }
+.hero h1 span { color: var(--purple); }
+.hero-lede { max-width: 480px; margin: 1.5rem 0 0; color: var(--muted); font-size: 1.08rem; line-height: 1.75; }
+.hero-actions { display: flex; flex-wrap: wrap; gap: .8rem; margin-top: 1.8rem; }
+.button { display: inline-flex; min-height: 3rem; align-items: center; justify-content: center; gap: .7rem; border: 1px solid transparent; border-radius: .75rem; padding: .78rem 1.2rem; font-size: .9rem; font-weight: 680; transition: transform .18s ease, background .18s ease, box-shadow .18s ease; }
+.button:hover { transform: translateY(-2px); }
+.button-primary { background: var(--purple); color: white; box-shadow: 0 8px 20px rgba(101,84,217,.2); }
+.button-primary:hover { background: var(--purple-dark); }
+.button-secondary { border-color: #dddfea; background: white; color: var(--ink); }
+.button-secondary:hover { border-color: #c9c4f6; }
+.hero-note { margin-top: 1.25rem; color: #85889a; font-size: .77rem; }
+.hero-visual { position: relative; display: flex; min-height: 490px; align-items: center; justify-content: center; }
+.visual-glow { position: absolute; width: 380px; height: 380px; border-radius: 50%; background: radial-gradient(circle, #e2defd 0%, #eeedfb 43%, rgba(247,248,252,0) 72%); }
+.visual-label { position: absolute; top: 2rem; left: 0; z-index: 2; border: 1px solid #e7e8f1; border-radius: 999px; background: rgba(255,255,255,.84); padding: .65rem 1rem; color: #55596b; font-size: .75rem; box-shadow: 0 8px 24px rgba(40,41,70,.06); }
+.visual-label .live-dot { width: .4rem; height: .4rem; }
+.phone-shot { position: relative; z-index: 1; width: min(290px, 63%); max-height: 485px; border: 8px solid #222230; border-radius: 2.4rem; object-fit: cover; object-position: top; box-shadow: 0 30px 60px rgba(27,28,49,.22); }
+.floating-card { position: absolute; z-index: 2; display: flex; align-items: center; gap: .7rem; border: 1px solid #ebeaf3; border-radius: .9rem; background: rgba(255,255,255,.94); padding: .8rem .95rem; box-shadow: 0 12px 30px rgba(36,37,67,.1); }
+.floating-card-top { top: 27%; right: -1rem; }
+.floating-card-bottom { bottom: 12%; left: -.8rem; }
+.floating-card strong, .floating-card small { display: block; }
+.floating-card strong { color: var(--ink); font-size: .76rem; }
+.floating-card small { margin-top: .2rem; color: #85889a; font-size: .66rem; }
+.card-icon { display: grid; width: 2rem; height: 2rem; place-items: center; border-radius: .6rem; background: #eeecff; color: var(--purple); font-weight: 800; }
+.card-icon.green { background: #e5f7f0; color: #23a678; }
+.proof-band { border-block: 1px solid var(--line); background: white; }
+.proof-band .grid > div { display: flex; align-items: center; gap: .85rem; color: #525669; font-size: .88rem; font-weight: 580; }
+.proof-mark { color: #a3a5b5; font-family: ui-monospace, monospace; font-size: .75rem; }
+.section-heading { max-width: 680px; }
+.section-heading h2, .workflow-copy h2 { margin: 0; color: var(--ink); font-size: clamp(2.25rem, 4vw, 3.35rem); font-weight: 740; letter-spacing: -.065em; line-height: 1.08; }
+.section-heading h2 span { color: #a2a3b0; }
+.section-heading > p:last-child, .workflow-copy > p:not(.eyebrow) { max-width: 600px; margin: 1.2rem 0 0; color: var(--muted); font-size: 1rem; line-height: 1.75; }
+.feature-card { min-height: 240px; border: 1px solid #e9eaf1; border-radius: 1.1rem; background: white; padding: 1.5rem; box-shadow: 0 7px 22px rgba(30,32,58,.035); }
+.feature-icon { display: grid; width: 2.7rem; height: 2.7rem; place-items: center; border-radius: .8rem; font-size: 1.15rem; font-weight: 750; }
+.feature-icon.violet { background: #efedff; color: #6554d9; }
+.feature-icon.blue { background: #e9f2ff; color: #4084df; }
+.feature-icon.amber { background: #fff4df; color: #c78921; font-size: .8rem; }
+.feature-card h3 { margin: 1.4rem 0 .55rem; color: var(--ink); font-size: 1.05rem; letter-spacing: -.025em; }
+.feature-card p { margin: 0; color: var(--muted); font-size: .88rem; line-height: 1.7; }
+.workflow-section { overflow: hidden; background: #eeeff7; }
+.workflow-visual { position: relative; min-height: 340px; display: flex; align-items: center; justify-content: center; }
+.terminal-window { width: min(100%, 440px); overflow: hidden; border: 1px solid #34354b; border-radius: 1rem; background: #1d1e30; box-shadow: 0 25px 60px rgba(33,34,58,.2); transform: rotate(-2deg); }
+.terminal-bar { display: flex; height: 2.6rem; align-items: center; gap: .4rem; border-bottom: 1px solid #34354b; padding: 0 .9rem; }
+.terminal-bar i { width: .5rem; height: .5rem; border-radius: 50%; background: #fb7185; }
+.terminal-bar i:nth-child(2) { background: #f7c65b; }.terminal-bar i:nth-child(3) { background: #42c994; }
+.terminal-bar span { margin-left: auto; margin-right: auto; color: #77798e; font: .68rem ui-monospace, monospace; }
+.terminal-body { padding: 1.25rem 1.5rem 1.4rem; color: #d8d9e7; font: .82rem/1.8 ui-monospace, monospace; }
+.terminal-body p { margin: .1rem 0; }.terminal-body b { color: #8f82fa; }.terminal-muted { color: #85879b; }.terminal-rule { height: 1px; margin: .55rem 0; background: #35364a; }
+.term-green { color: #42c994; }.term-blue { color: #69a9ff; }.term-purple { color: #a28afa; }
+.workflow-tag { position: absolute; right: 0; bottom: 2rem; border: 1px solid #dfe0eb; border-radius: 999px; background: white; padding: .7rem 1rem; color: #56596d; font-size: .75rem; box-shadow: 0 10px 24px rgba(35,36,61,.07); }
+.workflow-tag span { padding: 0 .35rem; color: #b1b2bf; }
+.workflow-copy code { border-radius: .35rem; background: #e4e2f2; padding: .12rem .35rem; color: #5244bc; font-size: .9em; }
+.text-link { display: inline-flex; gap: .65rem; margin-top: 1.3rem; color: var(--purple); font-size: .9rem; font-weight: 700; }
+.cta-panel { position: relative; display: flex; min-height: 270px; align-items: center; justify-content: space-between; gap: 2rem; overflow: hidden; border-radius: 1.5rem; background: #25243e; padding: 3rem 3.5rem; color: white; }
+.cta-panel > *:not(.cta-orbit) { position: relative; z-index: 1; }
+.cta-panel .eyebrow { color: #aaa1ff; }
+.cta-panel h2 { margin: 0; font-size: clamp(2rem, 4vw, 3rem); font-weight: 730; letter-spacing: -.06em; line-height: 1.05; }
+.button-light { background: white; color: #292744; }
+.button-light:hover { background: #eeecff; }
+.cta-orbit { position: absolute; border: 1px solid rgba(172,164,255,.18); border-radius: 50%; }
+.orbit-one { width: 420px; height: 420px; right: 9%; top: -270px; }.orbit-two { width: 520px; height: 520px; right: 0; bottom: -410px; }
+.site-footer { border-top: 1px solid var(--line); background: white; }
+.site-footer .brand { font-size: .95rem; }.footer-links { display: flex; gap: 1.5rem; color: #606477; font-size: .82rem; }.copyright { margin: 0; color: #9295a4; font-size: .75rem; }
+.legal-page h1 { margin: .3rem 0 1rem; color: var(--ink); font-size: clamp(2.7rem, 6vw, 4.1rem); font-weight: 760; letter-spacing: -.07em; line-height: 1.05; }
+.back-link { color: #707386; font-size: .85rem; font-weight: 600; }.back-link:hover { color: var(--purple); }
+.legal-intro { max-width: 700px; margin: 0; color: #626679; font-size: 1.1rem; line-height: 1.75; }
+.effective-date { margin: 1.2rem 0 3rem; color: #999baa; font-size: .8rem; }
+.legal-page section { margin-top: 2.1rem; border-top: 1px solid var(--line); padding-top: 1.4rem; }
+.legal-page section h2 { margin: 0 0 .7rem; color: var(--ink); font-size: 1.2rem; letter-spacing: -.03em; }
+.legal-page section p, .legal-page section li { color: #626679; font-size: .95rem; line-height: 1.8; }
+.legal-page section p { margin: .6rem 0; }
+.legal-page section a { color: var(--purple); text-decoration: underline; text-underline-offset: 3px; }
+.support-actions { display: flex; flex-wrap: wrap; gap: .75rem; margin-top: 1.6rem; }
+.support-page section:first-of-type { margin-top: 3rem; }
+.support-list { margin: .4rem 0 0; padding-left: 1.2rem; }
+
+@media (max-width: 700px) {
+  .nav-links { display: none; position: absolute; top: calc(100% - 1px); left: 0; right: 0; flex-direction: column; align-items: stretch; gap: 0; border-bottom: 1px solid var(--line); background: white; padding: .5rem 1.5rem 1rem; }
+  .nav-links.is-open { display: flex; }
+  .nav-links a { padding: .85rem .3rem; }
+  .nav-links .nav-cta { margin-top: .4rem; text-align: center; }
+  .menu-toggle { display: block; border: 1px solid #e3e4ec; border-radius: .6rem; background: white; padding: .55rem .8rem; color: var(--ink); font-size: .8rem; font-weight: 650; }
+  .hero { gap: 1rem; padding-top: 3.5rem; }
+  .hero-visual { min-height: 430px; }
+  .phone-shot { width: min(250px, 60%); max-height: 410px; }
+  .visual-label { top: 1rem; left: 0; }
+  .floating-card-top { right: -.35rem; }.floating-card-bottom { left: -.25rem; bottom: 8%; }
+  .proof-band .grid > div { padding: .2rem 0; }
+  .workflow-visual { min-height: 300px; }
+  .workflow-tag { right: -.2rem; bottom: .5rem; }
+  .cta-panel { align-items: flex-start; flex-direction: column; padding: 2.2rem 1.6rem; }
+  .site-footer .copyright { margin-top: -.5rem; }
+}
+
+@media (prefers-reduced-motion: reduce) {
+  html { scroll-behavior: auto; }
+  *, *::before, *::after { transition-duration: .01ms !important; }
+}

+ 14 - 0
site/assets/site.js

@@ -0,0 +1,14 @@
+const menuToggle = document.querySelector('.menu-toggle');
+const navLinks = document.querySelector('.nav-links');
+
+if (menuToggle && navLinks) {
+  menuToggle.addEventListener('click', () => {
+    const isOpen = menuToggle.getAttribute('aria-expanded') === 'true';
+    menuToggle.setAttribute('aria-expanded', String(!isOpen));
+    navLinks.classList.toggle('is-open', !isOpen);
+  });
+}
+
+document.querySelectorAll('[data-year]').forEach((element) => {
+  element.textContent = new Date().getFullYear();
+});

Разница между файлами не показана из-за своего большого размера
+ 6 - 0
site/assets/tailwind.js


+ 121 - 0
site/index.html

@@ -0,0 +1,121 @@
+<!doctype html>
+<html lang="en">
+<head>
+  <meta charset="utf-8">
+  <meta name="viewport" content="width=device-width, initial-scale=1">
+  <meta name="theme-color" content="#f7f8fc">
+  <meta name="description" content="RackPeek helps you document and manage your home lab infrastructure, from your own server or your phone.">
+  <title>RackPeek — your home lab, in view</title>
+  <link rel="icon" href="assets/rackpeek-logo.svg" type="image/svg+xml">
+  <link rel="stylesheet" href="assets/site.css">
+  <style type="text/tailwindcss">@import "tailwindcss";</style>
+  <script src="assets/tailwind.js"></script>
+</head>
+<body class="min-h-screen bg-slate-50 text-slate-900 antialiased">
+  <header class="site-header">
+    <nav class="mx-auto flex max-w-6xl items-center justify-between px-6 py-5" aria-label="Main navigation">
+      <a class="brand flex items-center gap-3" href="index.html">
+        <img src="assets/rackpeek-logo.svg" width="36" height="36" alt="">
+        <span>RackPeek</span>
+      </a>
+      <button class="menu-toggle" type="button" aria-expanded="false" aria-controls="nav-links">Menu</button>
+      <div class="nav-links" id="nav-links">
+        <a href="#features">Features</a>
+        <a href="https://timmoth.github.io/RackPeek/docs/overview">Documentation</a>
+        <a href="support.html">Support</a>
+        <a class="nav-cta" href="https://github.com/Timmoth/RackPeek">View on GitHub <span aria-hidden="true">↗</span></a>
+      </div>
+    </nav>
+  </header>
+
+  <main>
+    <section class="hero mx-auto grid max-w-6xl items-center gap-12 px-6 py-16 md:grid-cols-2 md:py-24">
+      <div class="hero-copy">
+        <p class="eyebrow"><span class="status-dot"></span> Made for the home lab</p>
+        <h1>Your whole<br><span>home lab,</span><br>in view.</h1>
+        <p class="hero-lede">Keep hardware, systems, services and networks documented in one clear, self-hosted inventory. Check in from the web, CLI or your phone.</p>
+        <div class="hero-actions">
+          <a class="button button-primary" href="https://github.com/Timmoth/RackPeek#running-rackpeek-with-docker">Get started <span aria-hidden="true">→</span></a>
+          <a class="button button-secondary" href="https://github.com/Timmoth/RackPeek">Explore the project</a>
+        </div>
+        <p class="hero-note">Open source · Your data stays yours · No telemetry</p>
+      </div>
+      <div class="hero-visual" aria-label="RackPeek mobile app overview">
+        <div class="visual-glow"></div>
+        <div class="visual-label"><span class="live-dot"></span> Your infrastructure at a glance</div>
+        <img class="phone-shot" src="assets/app-overview.jpg" alt="RackPeek app showing a home lab overview with hardware, systems, services and tags">
+        <div class="floating-card floating-card-top"><span class="card-icon">⌘</span><span><strong>One inventory</strong><small>Hardware to services</small></span></div>
+        <div class="floating-card floating-card-bottom"><span class="card-icon green">↗</span><span><strong>Self-hosted</strong><small>On your infrastructure</small></span></div>
+      </div>
+    </section>
+
+    <section class="proof-band">
+      <div class="mx-auto grid max-w-6xl gap-5 px-6 py-7 sm:grid-cols-3">
+        <div><span class="proof-mark">01</span><span>Document what you run</span></div>
+        <div><span class="proof-mark">02</span><span>See how it connects</span></div>
+        <div><span class="proof-mark">03</span><span>Keep control of your data</span></div>
+      </div>
+    </section>
+
+    <section class="section mx-auto max-w-6xl px-6 py-20" id="features">
+      <div class="section-heading">
+        <p class="eyebrow">A clearer picture</p>
+        <h2>Know what’s in your rack.<br><span>And what runs on it.</span></h2>
+        <p>RackPeek brings your infrastructure notes and relationships together, without turning your home lab into an enterprise project.</p>
+      </div>
+      <div class="feature-grid mt-12 grid gap-5 md:grid-cols-3">
+        <article class="feature-card">
+          <div class="feature-icon violet">▦</div>
+          <h3>Inventory that makes sense</h3>
+          <p>Keep servers, switches, routers, firewalls, access points, UPS units and other hardware alongside the systems and services they host.</p>
+        </article>
+        <article class="feature-card">
+          <div class="feature-icon blue">⌘</div>
+          <h3>See the relationships</h3>
+          <p>Follow the connections from physical hardware to virtual systems and services with useful trees, summaries and network views.</p>
+        </article>
+        <article class="feature-card">
+          <div class="feature-icon amber">{ }</div>
+          <h3>Your inventory, your way</h3>
+          <p>Store your configuration as readable YAML. Use the web UI, a scriptable CLI, or the mobile app to keep things up to date.</p>
+        </article>
+      </div>
+    </section>
+
+    <section class="workflow-section">
+      <div class="mx-auto grid max-w-6xl items-center gap-12 px-6 py-20 md:grid-cols-2">
+        <div class="workflow-visual">
+          <div class="terminal-window">
+            <div class="terminal-bar"><i></i><i></i><i></i><span>rpk · terminal</span></div>
+            <div class="terminal-body"><p><b>$</b> rpk summary</p><p class="terminal-muted">Infrastructure overview</p><div class="terminal-rule"></div><p><span class="term-green">●</span> 7 hardware resources</p><p><span class="term-blue">●</span> 2 systems</p><p><span class="term-purple">●</span> 2 services</p><p class="terminal-muted">Config: ./config/config.yaml</p></div>
+          </div>
+          <div class="workflow-tag">Web UI <span>·</span> CLI <span>·</span> iOS</div>
+        </div>
+        <div class="workflow-copy">
+          <p class="eyebrow">Fits your workflow</p>
+          <h2>Start with what<br>you already know.</h2>
+          <p>Run RackPeek on your own server. Add and update resources in the web interface, automate with <code>rpk</code>, or use the iOS companion app when you’re away from your desk.</p>
+          <a class="text-link" href="https://timmoth.github.io/RackPeek/docs/install-guide">Read the installation guide <span aria-hidden="true">→</span></a>
+        </div>
+      </div>
+    </section>
+
+    <section class="cta-section mx-auto max-w-6xl px-6 py-20">
+      <div class="cta-panel">
+        <div><p class="eyebrow">Your lab. Your notes. Your call.</p><h2>Make your setup<br>easier to understand.</h2></div>
+        <a class="button button-light" href="https://github.com/Timmoth/RackPeek#running-rackpeek-with-docker">Get started with RackPeek <span aria-hidden="true">→</span></a>
+        <div class="cta-orbit orbit-one"></div><div class="cta-orbit orbit-two"></div>
+      </div>
+    </section>
+  </main>
+
+  <footer class="site-footer">
+    <div class="mx-auto flex max-w-6xl flex-col gap-6 px-6 py-8 sm:flex-row sm:items-center sm:justify-between">
+      <a class="brand flex items-center gap-3" href="index.html"><img src="assets/rackpeek-logo.svg" width="28" height="28" alt=""><span>RackPeek</span></a>
+      <div class="footer-links"><a href="privacy.html">Privacy</a><a href="support.html">Support</a><a href="https://github.com/Timmoth/RackPeek">GitHub ↗</a></div>
+      <p class="copyright">© <span data-year>2026</span> RackPeek</p>
+    </div>
+  </footer>
+  <script src="assets/site.js"></script>
+</body>
+</html>

+ 36 - 0
site/privacy.html

@@ -0,0 +1,36 @@
+<!doctype html>
+<html lang="en">
+<head>
+  <meta charset="utf-8">
+  <meta name="viewport" content="width=device-width, initial-scale=1">
+  <meta name="theme-color" content="#f7f8fc">
+  <meta name="description" content="RackPeek for iOS privacy policy.">
+  <title>Privacy Policy — RackPeek</title>
+  <link rel="icon" href="assets/rackpeek-logo.svg" type="image/svg+xml">
+  <link rel="stylesheet" href="assets/site.css">
+  <style type="text/tailwindcss">@import "tailwindcss";</style>
+  <script src="assets/tailwind.js"></script>
+</head>
+<body class="min-h-screen bg-slate-50 text-slate-900 antialiased">
+  <header class="site-header"><nav class="mx-auto flex max-w-6xl items-center justify-between px-6 py-5" aria-label="Main navigation">
+    <a class="brand flex items-center gap-3" href="index.html"><img src="assets/rackpeek-logo.svg" width="36" height="36" alt=""><span>RackPeek</span></a>
+    <button class="menu-toggle" type="button" aria-expanded="false" aria-controls="nav-links">Menu</button>
+    <div class="nav-links" id="nav-links"><a href="index.html#features">Features</a><a href="https://timmoth.github.io/RackPeek/docs/overview">Documentation</a><a href="support.html">Support</a><a class="nav-cta" href="https://github.com/Timmoth/RackPeek">View on GitHub <span aria-hidden="true">↗</span></a></div>
+  </nav></header>
+  <main class="legal-page mx-auto max-w-3xl px-6 py-16 md:py-24">
+    <a class="back-link" href="index.html">← Back to RackPeek</a>
+    <p class="eyebrow mt-10">Privacy</p>
+    <h1>Privacy policy</h1>
+    <p class="legal-intro">RackPeek for iOS is a companion app for a RackPeek server that you choose and operate. This policy describes the app's current data handling.</p>
+    <p class="effective-date">Effective date: 22 October 2026</p>
+    <section><h2>Information the app uses</h2><p>To connect, you enter a RackPeek server address and API key. The app stores these values in iOS secure storage on your device so it can reconnect. The app sends the API key with requests to the server address you entered. Inventory information is received from that server and displayed in the app. Changes you make are sent back to that server.</p><p>The RackPeek app publisher does not receive this connection information or inventory through an app-operated service. Your RackPeek server may keep its own logs or inventory data under the configuration and policies set by its operator. Contact that operator with questions about their server.</p></section>
+    <section><h2>Network security</h2><p>Use HTTPS when your server supports it. If you connect over unencrypted HTTP, the API key and inventory traffic may be visible to other systems on that network. Only connect to a server you trust.</p></section>
+    <section><h2>Analytics and tracking</h2><p>The current app source does not include advertising, analytics, or cross-app tracking features. This policy must be reviewed if third-party SDKs or new data-handling features are added.</p></section>
+    <section><h2>Your choices</h2><p>You can disconnect from the server in the app's settings. Disconnecting removes the saved server address and API key from this device. It does not delete data from your RackPeek server; manage server data with the server's own tools.</p></section>
+    <section><h2>Children's privacy</h2><p>RackPeek is a general-purpose infrastructure inventory tool and is not designed for children.</p></section>
+    <section><h2>Changes and contact</h2><p>This policy may be updated when app behavior changes. For privacy questions, contact the app's publisher through the project's public support page: <a href="https://github.com/Timmoth/RackPeek/issues">GitHub Issues</a>.</p></section>
+  </main>
+  <footer class="site-footer"><div class="mx-auto flex max-w-6xl flex-col gap-6 px-6 py-8 sm:flex-row sm:items-center sm:justify-between"><a class="brand flex items-center gap-3" href="index.html"><img src="assets/rackpeek-logo.svg" width="28" height="28" alt=""><span>RackPeek</span></a><div class="footer-links"><a href="privacy.html">Privacy</a><a href="support.html">Support</a><a href="https://github.com/Timmoth/RackPeek">GitHub ↗</a></div><p class="copyright">© <span data-year>2026</span> RackPeek</p></div></footer>
+  <script src="assets/site.js"></script>
+</body>
+</html>

+ 33 - 0
site/support.html

@@ -0,0 +1,33 @@
+<!doctype html>
+<html lang="en">
+<head>
+  <meta charset="utf-8">
+  <meta name="viewport" content="width=device-width, initial-scale=1">
+  <meta name="theme-color" content="#f7f8fc">
+  <meta name="description" content="Get support for RackPeek by reading the docs or creating an issue on GitHub.">
+  <title>Support — RackPeek</title>
+  <link rel="icon" href="assets/rackpeek-logo.svg" type="image/svg+xml">
+  <link rel="stylesheet" href="assets/site.css">
+  <style type="text/tailwindcss">@import "tailwindcss";</style>
+  <script src="assets/tailwind.js"></script>
+</head>
+<body class="min-h-screen bg-slate-50 text-slate-900 antialiased">
+  <header class="site-header"><nav class="mx-auto flex max-w-6xl items-center justify-between px-6 py-5" aria-label="Main navigation">
+    <a class="brand flex items-center gap-3" href="index.html"><img src="assets/rackpeek-logo.svg" width="36" height="36" alt=""><span>RackPeek</span></a>
+    <button class="menu-toggle" type="button" aria-expanded="false" aria-controls="nav-links">Menu</button>
+    <div class="nav-links" id="nav-links"><a href="index.html#features">Features</a><a href="https://timmoth.github.io/RackPeek/docs/overview">Documentation</a><a href="support.html" aria-current="page">Support</a><a class="nav-cta" href="https://github.com/Timmoth/RackPeek">View on GitHub <span aria-hidden="true">↗</span></a></div>
+  </nav></header>
+  <main class="legal-page support-page mx-auto max-w-3xl px-6 py-16 md:py-24">
+    <a class="back-link" href="index.html">← Back to RackPeek</a>
+    <p class="eyebrow mt-10">We’re here to help</p>
+    <h1>RackPeek support</h1>
+    <p class="legal-intro">Need a hand with RackPeek? Start with the documentation. If you still need help, create an issue in the project’s GitHub repository.</p>
+    <div class="support-actions"><a class="button button-primary" href="https://github.com/Timmoth/RackPeek/issues/new/choose">Create a GitHub issue <span aria-hidden="true">↗</span></a><a class="button button-secondary" href="https://timmoth.github.io/RackPeek/docs/overview">Browse the documentation <span aria-hidden="true">→</span></a></div>
+    <section><h2>What to include</h2><p>Share what you were trying to do, what happened, and the steps to reproduce it. Include relevant error messages and your RackPeek version. Remove API keys, passwords, public IPs and other private details before posting.</p></section>
+    <section><h2>Using the iOS app?</h2><p>The app connects directly to the RackPeek server you choose. If you have a connection or inventory issue, include whether it happens in the mobile app, web UI or CLI, and check with the server operator if you don't run that server yourself.</p></section>
+    <section><h2>Useful links</h2><ul class="support-list"><li><a href="https://timmoth.github.io/RackPeek/docs/install-guide">Installation guide</a></li><li><a href="https://timmoth.github.io/RackPeek/docs/cli-commands">CLI commands reference</a></li><li><a href="https://github.com/Timmoth/RackPeek">RackPeek on GitHub</a></li></ul></section>
+  </main>
+  <footer class="site-footer"><div class="mx-auto flex max-w-6xl flex-col gap-6 px-6 py-8 sm:flex-row sm:items-center sm:justify-between"><a class="brand flex items-center gap-3" href="index.html"><img src="assets/rackpeek-logo.svg" width="28" height="28" alt=""><span>RackPeek</span></a><div class="footer-links"><a href="privacy.html">Privacy</a><a href="support.html">Support</a><a href="https://github.com/Timmoth/RackPeek">GitHub ↗</a></div><p class="copyright">© <span data-year>2026</span> RackPeek</p></div></footer>
+  <script src="assets/site.js"></script>
+</body>
+</html>

Некоторые файлы не были показаны из-за большого количества измененных файлов