Add app source and tests
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
36
lib/Peach/see/redis/ConnectRedis.dart
Normal file
36
lib/Peach/see/redis/ConnectRedis.dart
Normal file
@@ -0,0 +1,36 @@
|
||||
import '../../about/external/data/Redis.dart';
|
||||
|
||||
/// One scan's worth of results — the key list plus the context needed to
|
||||
/// label it (per-database counts, DBSIZE, and per-key types when filtering).
|
||||
class RedisScan {
|
||||
final int databaseCount;
|
||||
final Map<int, int> databaseCounts;
|
||||
final int total;
|
||||
final List<String> keys;
|
||||
final Map<String, String> keyTypes;
|
||||
|
||||
const RedisScan({
|
||||
required this.databaseCount,
|
||||
required this.databaseCounts,
|
||||
required this.total,
|
||||
required this.keys,
|
||||
required this.keyTypes,
|
||||
});
|
||||
}
|
||||
|
||||
abstract class ConnectRedis {
|
||||
/// A scan completed.
|
||||
void onScanned(RedisScan scan);
|
||||
|
||||
/// The scan couldn't run; [message] is already phrased for the user.
|
||||
void onScanFailed(String message);
|
||||
|
||||
/// A password was read out of the server's redis.conf.
|
||||
void onPasswordDetected(String password);
|
||||
|
||||
/// No `requirepass` was present in redis.conf.
|
||||
void onPasswordMissing();
|
||||
|
||||
/// One key's value was read.
|
||||
void onValueLoaded(RedisEntry entry);
|
||||
}
|
||||
14
lib/Peach/see/redis/Redis.dart
Normal file
14
lib/Peach/see/redis/Redis.dart
Normal file
@@ -0,0 +1,14 @@
|
||||
import 'package:flutter/cupertino.dart';
|
||||
|
||||
import '../../about/internal/application/ServerProfile.dart';
|
||||
import 'RedisState.dart';
|
||||
|
||||
/// Browse and read Redis keys/values on the server via `redis-cli` over SSH.
|
||||
class Redis extends StatefulWidget {
|
||||
final ServerProfile server;
|
||||
|
||||
const Redis({super.key, required this.server});
|
||||
|
||||
@override
|
||||
State<Redis> createState() => RedisState();
|
||||
}
|
||||
792
lib/Peach/see/redis/RedisState.dart
Normal file
792
lib/Peach/see/redis/RedisState.dart
Normal file
@@ -0,0 +1,792 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:stacked/stacked.dart';
|
||||
|
||||
import '../../about/external/data/Redis.dart';
|
||||
import '../../about/internal/application/ServerProfile.dart';
|
||||
import '../../about/internal/application/TextType.dart';
|
||||
import '../../designs/text/Text.dart';
|
||||
import '../../utils/Colors.dart';
|
||||
import 'ConnectRedis.dart';
|
||||
import 'Redis.dart';
|
||||
import 'ViewRedis.dart';
|
||||
|
||||
class RedisState extends State<Redis> implements ConnectRedis {
|
||||
static const _types = [
|
||||
'all',
|
||||
'string',
|
||||
'list',
|
||||
'set',
|
||||
'hash',
|
||||
'zset',
|
||||
'stream',
|
||||
];
|
||||
|
||||
ViewRedis? _model;
|
||||
|
||||
final _host = TextEditingController(text: '127.0.0.1');
|
||||
final _port = TextEditingController(text: '6379');
|
||||
final _password = TextEditingController();
|
||||
final _pattern = TextEditingController(text: '*');
|
||||
|
||||
int _selectedDb = 0;
|
||||
int _dbCount = 16;
|
||||
Map<int, int> _dbCounts = {};
|
||||
String _typeFilter = 'all';
|
||||
|
||||
List<String> _keys = [];
|
||||
Map<String, String> _keyTypes = {};
|
||||
int _total = 0;
|
||||
bool _loading = true;
|
||||
bool _detecting = false;
|
||||
bool _connected = false;
|
||||
String? _error;
|
||||
|
||||
String? _selectedKey;
|
||||
RedisEntry? _entry;
|
||||
bool _loadingValue = false;
|
||||
bool _wide = false;
|
||||
|
||||
ServerProfile get _server => widget.server;
|
||||
|
||||
int get _portNumber => int.tryParse(_port.text.trim()) ?? 6379;
|
||||
|
||||
String get _hostText =>
|
||||
_host.text.trim().isEmpty ? '127.0.0.1' : _host.text.trim();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_host.dispose();
|
||||
_port.dispose();
|
||||
_password.dispose();
|
||||
_pattern.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ViewModelBuilder<ViewRedis>.reactive(
|
||||
viewModelBuilder: () => ViewRedis(context, this),
|
||||
onViewModelReady: _onReady,
|
||||
builder: (context, viewModel, child) => Scaffold(
|
||||
appBar: AppBar(
|
||||
title: text(
|
||||
'Redis · ${_server.name}',
|
||||
20,
|
||||
TextType.Bold,
|
||||
color: context.jp.text,
|
||||
),
|
||||
actions: [
|
||||
IconButton(
|
||||
tooltip: 'Refresh',
|
||||
onPressed: _loading ? null : _onScan,
|
||||
icon: const Icon(Icons.refresh),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
],
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
_controls(),
|
||||
if (_loading) const LinearProgressIndicator(minHeight: 3),
|
||||
if (_error != null) _errorBar(),
|
||||
Expanded(
|
||||
child: LayoutBuilder(
|
||||
builder: (context, c) {
|
||||
_wide = c.maxWidth >= 760;
|
||||
if (!_wide) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 16),
|
||||
child: _keyList(),
|
||||
);
|
||||
}
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 16),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Expanded(flex: 4, child: _keyList()),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
flex: 5,
|
||||
child: _entry == null && !_loadingValue
|
||||
? _placeholder()
|
||||
: _valuePanel(_entry),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _onReady(ViewRedis model) {
|
||||
_model = model;
|
||||
// onViewModelReady fires mid-build; a synchronous setState from the
|
||||
// first load would dirty this State while it is still being built.
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) _initiate();
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _initiate() async {
|
||||
_scan();
|
||||
}
|
||||
|
||||
void _scan({bool triedDetect = false}) {
|
||||
setState(() {
|
||||
_loading = true;
|
||||
_error = null;
|
||||
});
|
||||
_model?.scan(
|
||||
_server,
|
||||
host: _hostText,
|
||||
port: _portNumber,
|
||||
password: _password.text,
|
||||
database: _selectedDb,
|
||||
pattern: _pattern.text,
|
||||
typeFilter: _typeFilter,
|
||||
triedDetect: triedDetect,
|
||||
);
|
||||
}
|
||||
|
||||
// ── named handlers ─────────────────────────────────────────────────────────
|
||||
|
||||
void _onScan() => _scan();
|
||||
|
||||
void _onPatternSubmitted(String _) => _scan();
|
||||
|
||||
void _onDetectPassword() {
|
||||
setState(() => _detecting = true);
|
||||
_model?.detectPassword(_server);
|
||||
}
|
||||
|
||||
void _onSelectDatabase(int db) {
|
||||
if (_selectedDb == db) return;
|
||||
setState(() => _selectedDb = db);
|
||||
_scan();
|
||||
}
|
||||
|
||||
void _onSelectType(String type) {
|
||||
if (_typeFilter == type) return;
|
||||
setState(() => _typeFilter = type);
|
||||
_scan();
|
||||
}
|
||||
|
||||
void _onSelectKey(String key) {
|
||||
setState(() {
|
||||
_selectedKey = key;
|
||||
_entry = null;
|
||||
_loadingValue = true;
|
||||
});
|
||||
_model?.loadValue(
|
||||
_server,
|
||||
key,
|
||||
host: _hostText,
|
||||
port: _portNumber,
|
||||
password: _password.text,
|
||||
database: _selectedDb,
|
||||
);
|
||||
}
|
||||
|
||||
void _onCopyValue(RedisEntry entry) {
|
||||
Clipboard.setData(ClipboardData(text: entry.value));
|
||||
_notify('Value copied.');
|
||||
}
|
||||
|
||||
void _notify(String message) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(message)));
|
||||
}
|
||||
|
||||
void _showValueSheet(RedisEntry entry) {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: context.jp.surface,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
builder: (sheetContext) => Padding(
|
||||
padding: EdgeInsets.only(
|
||||
bottom: MediaQuery.of(sheetContext).viewInsets.bottom,
|
||||
),
|
||||
child: SizedBox(
|
||||
height: MediaQuery.of(sheetContext).size.height * 0.7,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: _valuePanel(entry, inSheet: true),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ── ConnectRedis ───────────────────────────────────────────────────────────
|
||||
|
||||
@override
|
||||
void onScanned(RedisScan scan) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_connected = true;
|
||||
_dbCount = scan.databaseCount;
|
||||
_dbCounts = scan.databaseCounts;
|
||||
_total = scan.total;
|
||||
_keys = scan.keys;
|
||||
_keyTypes = scan.keyTypes;
|
||||
_loading = false;
|
||||
_error = null;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void onScanFailed(String message) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_connected = false;
|
||||
_loading = false;
|
||||
_error = message;
|
||||
_keys = [];
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void onPasswordDetected(String password) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_password.text = password;
|
||||
_detecting = false;
|
||||
});
|
||||
_notify('Found the Redis password in redis.conf — connecting…');
|
||||
// triedDetect stops a second lookup if this password is also rejected.
|
||||
_scan(triedDetect: true);
|
||||
}
|
||||
|
||||
@override
|
||||
void onPasswordMissing() {
|
||||
if (!mounted) return;
|
||||
setState(() => _detecting = false);
|
||||
_notify('No requirepass found in redis.conf.');
|
||||
}
|
||||
|
||||
@override
|
||||
void onValueLoaded(RedisEntry entry) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_entry = entry;
|
||||
_loadingValue = false;
|
||||
});
|
||||
if (!_wide) _showValueSheet(entry);
|
||||
}
|
||||
|
||||
// ── screen-only widgets ────────────────────────────────────────────────────
|
||||
|
||||
Widget _controls() {
|
||||
final jp = context.jp;
|
||||
return Container(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 12),
|
||||
decoration: BoxDecoration(
|
||||
color: jp.surface,
|
||||
border: Border(bottom: BorderSide(color: jp.border)),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
flex: 3,
|
||||
child: TextField(
|
||||
controller: _host,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Host',
|
||||
isDense: true,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
SizedBox(
|
||||
width: 80,
|
||||
child: TextField(
|
||||
controller: _port,
|
||||
keyboardType: TextInputType.number,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Port',
|
||||
isDense: true,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _password,
|
||||
obscureText: true,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Password (requirepass)',
|
||||
isDense: true,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
OutlinedButton.icon(
|
||||
onPressed: _detecting ? null : _onDetectPassword,
|
||||
icon: _detecting
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.key, size: 18),
|
||||
label: text('Detect', 13, TextType.Bold, color: jp.textDim),
|
||||
style: OutlinedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 14,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _pattern,
|
||||
onSubmitted: _onPatternSubmitted,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Key pattern',
|
||||
hintText: 'user:* · *',
|
||||
prefixIcon: Icon(Icons.search, size: 20),
|
||||
isDense: true,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
FilledButton.icon(
|
||||
onPressed: _loading ? null : _onScan,
|
||||
icon: const Icon(Icons.travel_explore, size: 18),
|
||||
label: text('Scan', 13, TextType.Bold, color: colorWhite),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_dbSelector(),
|
||||
const SizedBox(height: 10),
|
||||
_typeSelector(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _dbSelector() {
|
||||
final jp = context.jp;
|
||||
return Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_selectorLabel(Icons.storage, 'Database'),
|
||||
const SizedBox(height: 6),
|
||||
Wrap(
|
||||
spacing: 6,
|
||||
runSpacing: 6,
|
||||
children: List.generate(_dbCount, (i) {
|
||||
final selected = _selectedDb == i;
|
||||
final count = _dbCounts[i] ?? 0;
|
||||
return InkWell(
|
||||
borderRadius: BorderRadius.circular(9),
|
||||
onTap: _loading ? null : () => _onSelectDatabase(i),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 11,
|
||||
vertical: 6,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: selected
|
||||
? PeachTheme.seed.withValues(alpha: 0.14)
|
||||
: jp.surface2,
|
||||
borderRadius: BorderRadius.circular(9),
|
||||
border: Border.all(
|
||||
color: selected
|
||||
? PeachTheme.seed.withValues(alpha: 0.7)
|
||||
: jp.border,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
text(
|
||||
'db$i',
|
||||
12.5,
|
||||
selected ? TextType.Bold : TextType.Regular,
|
||||
color: selected ? PeachTheme.seed : jp.text,
|
||||
),
|
||||
if (count > 0) ...[
|
||||
const SizedBox(width: 6),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 5,
|
||||
vertical: 1,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: selected
|
||||
? PeachTheme.seed.withValues(alpha: 0.22)
|
||||
: jp.border.withValues(alpha: 0.6),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: text(
|
||||
'$count',
|
||||
10.5,
|
||||
TextType.Bold,
|
||||
color: selected ? PeachTheme.seed : jp.textFaint,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _typeSelector() {
|
||||
final jp = context.jp;
|
||||
return Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_selectorLabel(Icons.category_outlined, 'Type filter'),
|
||||
const SizedBox(height: 6),
|
||||
Wrap(
|
||||
spacing: 6,
|
||||
runSpacing: 6,
|
||||
children: _types.map((type) {
|
||||
final selected = _typeFilter == type;
|
||||
final color = type == 'all' ? PeachTheme.slate : _typeColor(type);
|
||||
return InkWell(
|
||||
borderRadius: BorderRadius.circular(9),
|
||||
onTap: _loading ? null : () => _onSelectType(type),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 11,
|
||||
vertical: 6,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: selected
|
||||
? color.withValues(alpha: 0.14)
|
||||
: jp.surface2,
|
||||
borderRadius: BorderRadius.circular(9),
|
||||
border: Border.all(
|
||||
color: selected
|
||||
? color.withValues(alpha: 0.7)
|
||||
: jp.border,
|
||||
),
|
||||
),
|
||||
child: text(
|
||||
type == 'all' ? 'all types' : type,
|
||||
12.5,
|
||||
selected ? TextType.Bold : TextType.Regular,
|
||||
color: selected ? color : jp.text,
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _selectorLabel(IconData icon, String label) => Row(
|
||||
children: [
|
||||
Icon(icon, size: 14, color: context.jp.textFaint),
|
||||
const SizedBox(width: 6),
|
||||
text(
|
||||
label,
|
||||
11,
|
||||
TextType.Bold,
|
||||
color: context.jp.textFaint,
|
||||
letterSpacing: 0.4,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
Color _typeColor(String type) {
|
||||
switch (type) {
|
||||
case 'string':
|
||||
return PeachTheme.accent;
|
||||
case 'list':
|
||||
return PeachTheme.warn;
|
||||
case 'set':
|
||||
return PeachTheme.violet;
|
||||
case 'hash':
|
||||
return PeachTheme.seed;
|
||||
case 'zset':
|
||||
return PeachTheme.mint;
|
||||
case 'stream':
|
||||
return PeachTheme.pink;
|
||||
default:
|
||||
return PeachTheme.slate;
|
||||
}
|
||||
}
|
||||
|
||||
Widget _errorBar() => Container(
|
||||
width: double.infinity,
|
||||
margin: const EdgeInsets.fromLTRB(16, 12, 16, 0),
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: PeachTheme.danger.withValues(alpha: 0.10),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: PeachTheme.danger.withValues(alpha: 0.5)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.error_outline, color: PeachTheme.danger),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: text(_error!, 14, TextType.Regular, color: PeachTheme.danger),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
Widget _keyList() {
|
||||
final jp = context.jp;
|
||||
return Container(
|
||||
decoration: PeachTheme.panel(context, radius: 16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(14, 12, 14, 10),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.key, size: 16, color: PeachTheme.seed),
|
||||
const SizedBox(width: 8),
|
||||
text(
|
||||
'KEYS',
|
||||
12,
|
||||
TextType.Bold,
|
||||
color: jp.textDim,
|
||||
letterSpacing: 1,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
text(
|
||||
'${_keys.length}',
|
||||
12,
|
||||
TextType.Bold,
|
||||
color: PeachTheme.seed,
|
||||
),
|
||||
if (_total > 0) ...[
|
||||
const SizedBox(width: 4),
|
||||
text(
|
||||
_pattern.text.trim() == '*'
|
||||
? 'of $_total'
|
||||
: '($_total total)',
|
||||
11.5,
|
||||
TextType.Regular,
|
||||
color: jp.textFaint,
|
||||
),
|
||||
],
|
||||
if (_keys.length >= 100000) ...[
|
||||
const SizedBox(width: 6),
|
||||
text('(capped)', 11, TextType.Regular, color: jp.textFaint),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
Divider(height: 1, color: jp.border),
|
||||
Expanded(
|
||||
child: _keys.isEmpty
|
||||
? Center(
|
||||
child: text(
|
||||
_loading
|
||||
? 'Scanning…'
|
||||
: (_connected ? 'No keys match.' : 'Not connected.'),
|
||||
14,
|
||||
TextType.Regular,
|
||||
color: jp.textFaint,
|
||||
),
|
||||
)
|
||||
: ListView.separated(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
itemCount: _keys.length,
|
||||
separatorBuilder: (_, i) => Divider(
|
||||
height: 1,
|
||||
color: jp.border,
|
||||
indent: 12,
|
||||
endIndent: 12,
|
||||
),
|
||||
itemBuilder: (_, i) => _keyRow(_keys[i]),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _keyRow(String key) {
|
||||
final jp = context.jp;
|
||||
final selected = _wide && key == _selectedKey;
|
||||
final type = _keyTypes[key];
|
||||
return InkWell(
|
||||
onTap: () => _onSelectKey(key),
|
||||
child: Container(
|
||||
color: selected ? PeachTheme.seed.withValues(alpha: 0.10) : null,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 11),
|
||||
child: Row(
|
||||
children: [
|
||||
if (type != null) ...[_typeBadge(type), const SizedBox(width: 9)],
|
||||
Expanded(
|
||||
child: text(
|
||||
key,
|
||||
13,
|
||||
TextType.Regular,
|
||||
color: selected ? PeachTheme.seed : jp.text,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
Icon(Icons.chevron_right, size: 18, color: jp.textFaint),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _typeBadge(String type) {
|
||||
final color = _typeColor(type);
|
||||
return Container(
|
||||
width: 46,
|
||||
alignment: Alignment.center,
|
||||
padding: const EdgeInsets.symmetric(vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: 0.14),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
border: Border.all(color: color.withValues(alpha: 0.4)),
|
||||
),
|
||||
child: text(type, 9.5, TextType.Bold, color: color, letterSpacing: 0.2),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _placeholder() {
|
||||
final jp = context.jp;
|
||||
return Container(
|
||||
decoration: PeachTheme.panel(context, radius: 16),
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.data_object, size: 40, color: jp.textFaint),
|
||||
const SizedBox(height: 10),
|
||||
text(
|
||||
'Select a key to view its value',
|
||||
14,
|
||||
TextType.Regular,
|
||||
color: jp.textDim,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _valuePanel(RedisEntry? entry, {bool inSheet = false}) {
|
||||
final jp = context.jp;
|
||||
if (_loadingValue) {
|
||||
return Container(
|
||||
decoration: inSheet ? null : PeachTheme.panel(context, radius: 16),
|
||||
child: const Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
}
|
||||
if (entry == null) return _placeholder();
|
||||
|
||||
final content = Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: text(
|
||||
entry.key,
|
||||
14,
|
||||
TextType.Bold,
|
||||
color: jp.text,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'Copy value',
|
||||
onPressed: () => _onCopyValue(entry),
|
||||
icon: const Icon(Icons.copy_all, size: 18),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
_tag(entry.type.toUpperCase(), PeachTheme.accent),
|
||||
const SizedBox(width: 8),
|
||||
_tag('TTL ${entry.ttlLabel}', jp.textDim),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Expanded(
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: jp.consoleBg,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: ConsoleColors.frame),
|
||||
),
|
||||
child: SingleChildScrollView(
|
||||
child: SelectableText(
|
||||
entry.value.isEmpty ? '(empty)' : entry.value,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 12.5,
|
||||
height: 1.4,
|
||||
color: ConsoleColors.body,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
if (inSheet) return content;
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: PeachTheme.panel(context, radius: 16),
|
||||
child: content,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _tag(String label, Color color) => Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: 0.14),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: text(label, 11, TextType.Bold, color: color),
|
||||
);
|
||||
}
|
||||
177
lib/Peach/see/redis/ViewRedis.dart
Normal file
177
lib/Peach/see/redis/ViewRedis.dart
Normal file
@@ -0,0 +1,177 @@
|
||||
import '../../about/external/data/Redis.dart';
|
||||
import '../../about/internal/application/ServerProfile.dart';
|
||||
import '../parent/ParentViewModel.dart';
|
||||
import 'ConnectRedis.dart';
|
||||
|
||||
class ViewRedis extends ParentViewModel {
|
||||
ConnectRedis connection;
|
||||
|
||||
ViewRedis(super.context, this.connection);
|
||||
|
||||
/// Pings Redis, then reads the database list, the current DB's size and the
|
||||
/// keys matching [pattern]. On a NOAUTH bounce with no password entered, it
|
||||
/// reads `requirepass` out of redis.conf once and retries — the common case
|
||||
/// where the user has SSH access but hasn't been told the Redis password.
|
||||
void scan(
|
||||
ServerProfile server, {
|
||||
required String host,
|
||||
required int port,
|
||||
required String password,
|
||||
required int database,
|
||||
required String pattern,
|
||||
required String typeFilter,
|
||||
bool triedDetect = false,
|
||||
}) async {
|
||||
if (!await canReach(
|
||||
server,
|
||||
() => scan(
|
||||
server,
|
||||
host: host,
|
||||
port: port,
|
||||
password: password,
|
||||
database: database,
|
||||
pattern: pattern,
|
||||
typeFilter: typeFilter,
|
||||
triedDetect: triedDetect,
|
||||
),
|
||||
)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
final (ok, detail) = await getDataManager().redisPing(
|
||||
server,
|
||||
host: host,
|
||||
port: port,
|
||||
pass: password,
|
||||
db: database,
|
||||
);
|
||||
|
||||
if (!ok) {
|
||||
if (!triedDetect &&
|
||||
detail.toUpperCase().contains('NOAUTH') &&
|
||||
password.trim().isEmpty) {
|
||||
final found = await getDataManager().redisConfigPassword(server);
|
||||
if (found.isNotEmpty) {
|
||||
connection.onPasswordDetected(found);
|
||||
return;
|
||||
}
|
||||
}
|
||||
connection.onScanFailed(_hint(detail, host, port));
|
||||
return;
|
||||
}
|
||||
|
||||
final effectivePattern = pattern.trim().isEmpty ? '*' : pattern.trim();
|
||||
|
||||
final (databaseCount, databaseCounts) = await getDataManager()
|
||||
.redisDatabases(server, host: host, port: port, pass: password);
|
||||
final total = await getDataManager().redisDbSize(
|
||||
server,
|
||||
host: host,
|
||||
port: port,
|
||||
pass: password,
|
||||
db: database,
|
||||
);
|
||||
|
||||
final keys = <String>[];
|
||||
final keyTypes = <String, String>{};
|
||||
|
||||
if (typeFilter == 'all') {
|
||||
keys.addAll(
|
||||
await getDataManager().redisKeys(
|
||||
server,
|
||||
pattern: effectivePattern,
|
||||
host: host,
|
||||
port: port,
|
||||
pass: password,
|
||||
db: database,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
final typed = await getDataManager().redisKeysTyped(
|
||||
server,
|
||||
pattern: effectivePattern,
|
||||
filterType: typeFilter,
|
||||
host: host,
|
||||
port: port,
|
||||
pass: password,
|
||||
db: database,
|
||||
);
|
||||
for (final (key, type) in typed) {
|
||||
keys.add(key);
|
||||
keyTypes[key] = type;
|
||||
}
|
||||
}
|
||||
|
||||
connection.onScanned(
|
||||
RedisScan(
|
||||
databaseCount: databaseCount,
|
||||
databaseCounts: databaseCounts,
|
||||
total: total,
|
||||
keys: keys,
|
||||
keyTypes: keyTypes,
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
connection.onScanFailed(error.toString());
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads `requirepass` from the server's redis.conf.
|
||||
void detectPassword(ServerProfile server) async {
|
||||
if (!await canReach(server, () => detectPassword(server))) return;
|
||||
try {
|
||||
final found = await getDataManager().redisConfigPassword(server);
|
||||
if (found.isEmpty) {
|
||||
connection.onPasswordMissing();
|
||||
} else {
|
||||
connection.onPasswordDetected(found);
|
||||
}
|
||||
} catch (error) {
|
||||
handleError(error, () => detectPassword(server), () {}, "Retry");
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads one key. A failure is surfaced as an `error`-typed entry rather
|
||||
/// than an overlay — the value panel is the natural place to show it.
|
||||
void loadValue(
|
||||
ServerProfile server,
|
||||
String key, {
|
||||
required String host,
|
||||
required int port,
|
||||
required String password,
|
||||
required int database,
|
||||
}) async {
|
||||
try {
|
||||
final entry = await getDataManager().redisGet(
|
||||
server,
|
||||
key,
|
||||
host: host,
|
||||
port: port,
|
||||
pass: password,
|
||||
db: database,
|
||||
);
|
||||
connection.onValueLoaded(entry);
|
||||
} catch (error) {
|
||||
connection.onValueLoaded(
|
||||
RedisEntry(key: key, type: 'error', ttl: '', value: '$error'),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Turns a raw redis-cli complaint into something the user can act on.
|
||||
String _hint(String detail, String host, int port) {
|
||||
final message = detail.isEmpty ? 'Redis did not respond.' : detail;
|
||||
if (message.toUpperCase().contains('NOAUTH')) {
|
||||
return 'Redis requires a password. Enter it below (or tap Detect to read '
|
||||
'it from redis.conf), then Scan.';
|
||||
}
|
||||
if (message.toLowerCase().contains('wrongpass')) {
|
||||
return 'Wrong Redis password. Fix it and Scan again.';
|
||||
}
|
||||
if (message.toLowerCase().contains('connection refused')) {
|
||||
return 'Nothing is listening on $host:$port. Check the host/port '
|
||||
'(Redis in Docker often maps a different port).';
|
||||
}
|
||||
return message;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user