Commit a0d8b3b8 authored by Mahmoud Aglan's avatar Mahmoud Aglan

feat(caprover): sorting, live log viewer — and fix broken per-app URLs

SORTING by last deployed (default), name, status or version. Apps with no deploy
timestamp sort last rather than as the epoch: "unknown" is not "ancient".
Building sorts above running above stopped, because the one that is changing is
the one you opened the tab for.

LIVE LOG VIEWER for both build output and runtime logs, polled every 3s.
CapRover exposes no log stream, and a poll is what survives a phone moving
between networks anyway. Follow-mode sticks to the bottom until you scroll up
and stops fighting you — reading history while lines arrive is the normal reason
to open a log. Build logs stop polling once the build ends rather than hitting
CapRover every three seconds forever. Errors and warnings are coloured, lines
are filterable, and the whole buffer copies in one tap.

FIXED, and this one was live: every per-app CapRover URL had an escaped dollar
sign — '/api/caprover/apps/\$name' — so the path was sent with a literal
backslash-dollar-name instead of the app. It came from a heredoc when the client
was first written. capApps() has no interpolation, so listing worked and I
verified against it; inspect, deploy, set-repo and logs were all broken and none
of them had been exercised. Testing the one endpoint that happened to work is
not testing the feature.
Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
parent c50315c4
......@@ -457,6 +457,43 @@ class CapRepo {
);
}
class CapLogLine {
const CapLogLine({required this.at, required this.text});
final String at;
final String text;
/// Docker's timestamp is UTC and full-precision; the list only has room for
/// clock time.
String get clock => at.length >= 19 ? at.substring(11, 19) : '';
factory CapLogLine.fromJson(Map<String, dynamic> j) =>
CapLogLine(at: _s(j['at']), text: _s(j['text']));
}
class CapBuild {
const CapBuild({
required this.building,
required this.failed,
required this.cursor,
required this.lines,
});
final bool building;
final bool failed;
/// CapRover's own cursor into the build output. It moving is how a restarted
/// build is told from one that simply has nothing new.
final int cursor;
final List<CapLogLine> lines;
factory CapBuild.fromJson(Map<String, dynamic> j) => CapBuild(
building: _b(j['building']),
failed: _b(j['failed']),
cursor: _i(j['firstLineNumber']),
lines: _list(j['lines'], CapLogLine.fromJson),
);
}
class CapApp {
const CapApp({
required this.name,
......@@ -465,6 +502,8 @@ class CapApp {
required this.building,
required this.instanceCount,
required this.repo,
this.lastDeployedAt,
this.gitHash = '',
this.hasPersistentData = false,
this.ssl = false,
this.forceSsl = false,
......@@ -480,6 +519,11 @@ class CapApp {
final bool building;
final int instanceCount;
final CapRepo repo;
/// When the running version was deployed. Null for apps whose version carries
/// no timestamp, which must sort last rather than as the epoch.
final DateTime? lastDeployedAt;
final String gitHash;
final bool hasPersistentData;
final bool ssl;
final bool forceSsl;
......@@ -501,6 +545,8 @@ class CapApp {
repo: j['repo'] is Map
? CapRepo.fromJson((j['repo'] as Map).cast<String, dynamic>())
: const CapRepo(),
lastDeployedAt: _s(j['lastDeployedAt']).isEmpty ? null : _dt(j['lastDeployedAt']),
gitHash: _s(j['gitHash']),
hasPersistentData: _b(j['hasPersistentData']),
ssl: _b(j['ssl']),
forceSsl: _b(j['forceSsl']),
......
......@@ -300,13 +300,13 @@ class SonApi {
CapOverview.fromJson(_obj(await _get('/api/caprover/apps')));
Future<CapApp> capApp(String name) async {
final j = _obj(await _get('/api/caprover/apps/\$name'));
final j = _obj(await _get('/api/caprover/apps/$name'));
return CapApp.fromJson((j['app'] as Map).cast<String, dynamic>());
}
/// Rebuild from the app's configured git repo, or deploy a specific image.
Future<void> capDeploy(String name, {String? image}) async =>
_send('POST', '/api/caprover/apps/\$name/deploy',
_send('POST', '/api/caprover/apps/$name/deploy',
body: {if (image != null && image.isNotEmpty) 'image': image});
Future<void> capSetRepo(
......@@ -316,7 +316,7 @@ class SonApi {
String? user,
String? password,
}) async =>
_send('POST', '/api/caprover/apps/\$name/repo', body: {
_send('POST', '/api/caprover/apps/$name/repo', body: {
'url': url,
if (branch != null) 'branch': branch,
if (user != null) 'user': user,
......@@ -324,9 +324,21 @@ class SonApi {
if (password != null && password.isNotEmpty) 'password': password,
});
Future<String> capLogs(String name) async {
final j = _obj(await _get('/api/caprover/apps/\$name/logs'));
return (j['logs'] ?? '').toString();
/// The build that produced the current version.
Future<CapBuild> capBuild(String name) async =>
CapBuild.fromJson(_obj(await _get('/api/caprover/apps/$name/logs')));
/// What the running container is saying right now — a different question from
/// the build log, and the one that matters when a deploy has gone wrong.
Future<List<CapLogLine>> capRuntimeLogs(String name) async {
final j = _obj(await _get('/api/caprover/apps/$name/runtime-logs'));
final l = j['lines'];
return l is List
? [
for (final e in l)
if (e is Map) CapLogLine.fromJson(e.cast<String, dynamic>()),
]
: const [];
}
// --- usage ----------------------------------------------------------------
......
......@@ -7,6 +7,7 @@ import '../../core/api/son_api.dart';
import '../../core/providers.dart';
import '../../core/theme.dart';
import '../../widgets/empty_state.dart';
import 'log_viewer.dart';
final capProvider = FutureProvider<CapOverview>((ref) async => ref.watch(apiProvider).capApps());
......@@ -26,6 +27,7 @@ class _CapRoverScreenState extends ConsumerState<CapRoverScreen> {
final _search = TextEditingController();
String _query = '';
bool _onlyGit = false;
_Sort _sort = _Sort.deployed;
@override
void dispose() {
......@@ -83,6 +85,7 @@ class _CapRoverScreenState extends ConsumerState<CapRoverScreen> {
a.image.toLowerCase().contains(q) ||
a.repo.url.toLowerCase().contains(q);
}).toList();
_apply(_sort, apps);
return Column(
children: [
......@@ -101,6 +104,45 @@ class _CapRoverScreenState extends ConsumerState<CapRoverScreen> {
Row(
children: [
const SizedBox(width: 16),
PopupMenuButton<_Sort>(
initialValue: _sort,
color: Ember.surface,
onSelected: (v) => setState(() => _sort = v),
itemBuilder: (_) => [
for (final s in _Sort.values)
PopupMenuItem(
value: s,
child: Row(
children: [
if (_sort == s)
const Icon(Icons.check, size: 15, color: Ember.flame)
else
const SizedBox(width: 15),
const SizedBox(width: 8),
Text(s.label, style: const TextStyle(fontSize: 13)),
],
),
),
],
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: Ember.surfaceHi,
borderRadius: BorderRadius.circular(999),
border: Border.all(color: Ember.border),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.sort, size: 13, color: Ember.flame),
const SizedBox(width: 6),
Text(_sort.label, style: const TextStyle(fontSize: 12)),
const Icon(Icons.expand_more, size: 14, color: Ember.textFaint),
],
),
),
),
const SizedBox(width: 8),
FilterChip(
label: const Text('Deploys from git'),
selected: _onlyGit,
......@@ -266,50 +308,13 @@ class _AppCardState extends ConsumerState<_AppCard> {
);
}
Future<void> _logs() async {
try {
final text = await _api.capLogs(a.name);
if (!mounted) return;
showModalBottomSheet(
context: context,
backgroundColor: Ember.surface,
isScrollControlled: true,
builder: (_) => DraggableScrollableSheet(
expand: false,
initialChildSize: 0.85,
builder: (_, ctrl) => Column(
children: [
const SizedBox(height: 14),
Text('${a.name} · build log',
style: const TextStyle(fontWeight: FontWeight.w600)),
const SizedBox(height: 10),
Expanded(
child: text.trim().isEmpty
? const Center(
child: Text('No build output', style: TextStyle(color: Ember.textDim)))
: SingleChildScrollView(
controller: ctrl,
padding: const EdgeInsets.all(14),
child: SelectableText(
text,
style: const TextStyle(
fontSize: 11,
height: 1.4,
fontFamilyFallback: Ember.mono,
color: Ember.textDim,
),
),
),
),
],
),
),
);
} on ApiException catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(e.message)));
}
}
void _showLogs(LogKind kind) {
showModalBottomSheet(
context: context,
backgroundColor: Ember.surface,
isScrollControlled: true,
builder: (_) => LogViewer(app: a.name, kind: kind),
);
}
@override
......@@ -361,6 +366,11 @@ class _AppCardState extends ConsumerState<_AppCard> {
style: const TextStyle(
fontSize: 11, color: Ember.textDim, fontFamilyFallback: Ember.mono),
),
if (a.lastDeployedAt != null) ...[
const SizedBox(height: 4),
Text('deployed ${_ago(a.lastDeployedAt!)}',
style: const TextStyle(fontSize: 10.5, color: Ember.textFaint)),
],
if (a.repo.isSet) ...[
const SizedBox(height: 5),
Row(
......@@ -461,7 +471,10 @@ class _AppCardState extends ConsumerState<_AppCard> {
children: [
_act(Icons.rocket_launch_outlined, 'Redeploy', _deploy),
_act(Icons.commit, 'Source', _editRepo),
_act(Icons.article_outlined, 'Log', _logs),
_act(Icons.construction, 'Build log',
() => _showLogs(LogKind.build)),
_act(Icons.terminal, 'Live log',
() => _showLogs(LogKind.runtime)),
],
),
const SizedBox(height: 4),
......@@ -501,3 +514,49 @@ class _AppCardState extends ConsumerState<_AppCard> {
),
);
}
enum _Sort {
deployed('Last deployed'),
name('Name'),
status('Status'),
version('Version');
const _Sort(this.label);
final String label;
}
/// Sorts in place. Apps with no deploy timestamp sort last rather than as the
/// epoch — "unknown" is not "ancient".
void _apply(_Sort sort, List<CapApp> apps) {
switch (sort) {
case _Sort.deployed:
apps.sort((x, y) {
final a = x.lastDeployedAt, b = y.lastDeployedAt;
if (a == null && b == null) return x.name.compareTo(y.name);
if (a == null) return 1;
if (b == null) return -1;
return b.compareTo(a);
});
case _Sort.name:
apps.sort((x, y) => x.name.compareTo(y.name));
case _Sort.status:
// Building first (it is changing), then running, then stopped.
int rank(CapApp a) => a.building ? 0 : (a.stopped ? 2 : 1);
apps.sort((x, y) {
final r = rank(x).compareTo(rank(y));
return r != 0 ? r : x.name.compareTo(y.name);
});
case _Sort.version:
apps.sort((x, y) => y.deployedVersion.compareTo(x.deployedVersion));
}
}
String _ago(DateTime t) {
final d = DateTime.now().difference(t);
if (d.inMinutes < 1) return 'just now';
if (d.inMinutes < 60) return '${d.inMinutes}m ago';
if (d.inHours < 24) return '${d.inHours}h ago';
if (d.inDays < 30) return '${d.inDays}d ago';
return '${(d.inDays / 30).floor()}mo ago';
}
This diff is collapsed.
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment