diff --git a/src/api/providers.zig b/src/api/providers.zig index 2e4221c..e06254e 100644 --- a/src/api/providers.zig +++ b/src/api/providers.zig @@ -105,14 +105,10 @@ pub fn handleCreate( .validated_with = component_name, }); - // Update validated_at on the just-added provider + // Record both the last successful validation and the latest validation attempt. const providers = state.savedProviders(); const new_id = providers[providers.len - 1].id; - const now = try nowIso8601(allocator); - defer allocator.free(now); - _ = try state.updateSavedProvider(new_id, .{ .validated_at = now }); - - try state.save(); + try persistValidationAttempt(allocator, state, new_id, component_name, true); // Return the saved provider const sp = state.getSavedProvider(new_id).?; @@ -162,7 +158,14 @@ pub fn handleUpdate( const probe_result = probeProvider(allocator, component_name, bin_path, existing.provider, effective_key, effective_model, ""); defer probe_result.deinit(allocator); + const now = try nowIso8601(allocator); + defer allocator.free(now); if (!probe_result.live_ok) { + _ = try state.updateSavedProvider(id, .{ + .last_validation_at = now, + .last_validation_ok = false, + }); + try state.save(); var buf = std.array_list.Managed(u8).init(allocator); errdefer buf.deinit(); try buf.appendSlice("{\"error\":\"Provider validation failed: "); @@ -171,15 +174,14 @@ pub fn handleUpdate( return buf.toOwnedSlice(); } - const now = nowIso8601(allocator) catch ""; - defer if (now.len > 0) allocator.free(now); - _ = try state.updateSavedProvider(id, .{ .name = parsed.value.name, .api_key = parsed.value.api_key, .model = parsed.value.model, .validated_at = now, .validated_with = component_name, + .last_validation_at = now, + .last_validation_ok = true, }); } else { // Name-only update @@ -224,12 +226,7 @@ pub fn handleValidate( const probe_result = probeProvider(allocator, component_name, bin_path, existing.provider, existing.api_key, existing.model, ""); defer probe_result.deinit(allocator); - if (probe_result.live_ok) { - const now = try nowIso8601(allocator); - defer allocator.free(now); - _ = try state.updateSavedProvider(id, .{ .validated_at = now, .validated_with = component_name }); - try state.save(); - } + try persistValidationAttempt(allocator, state, id, component_name, probe_result.live_ok); var buf = std.array_list.Managed(u8).init(allocator); errdefer buf.deinit(); @@ -289,6 +286,25 @@ fn maskApiKey(buf: *std.array_list.Managed(u8), key: []const u8) !void { } } +fn persistValidationAttempt( + allocator: std.mem.Allocator, + state: *state_mod.State, + id: u32, + component_name: []const u8, + live_ok: bool, +) !void { + const now = try nowIso8601(allocator); + defer allocator.free(now); + + _ = try state.updateSavedProvider(id, .{ + .validated_at = if (live_ok) now else null, + .validated_with = if (live_ok) component_name else null, + .last_validation_at = now, + .last_validation_ok = live_ok, + }); + try state.save(); +} + fn appendProviderJson(buf: *std.array_list.Managed(u8), sp: state_mod.SavedProvider, reveal: bool) !void { try buf.appendSlice("{\"id\":\"sp_"); var id_buf: [16]u8 = undefined; @@ -311,7 +327,11 @@ fn appendProviderJson(buf: *std.array_list.Managed(u8), sp: state_mod.SavedProvi try appendEscaped(buf, sp.validated_at); try buf.appendSlice("\",\"validated_with\":\""); try appendEscaped(buf, sp.validated_with); - try buf.appendSlice("\"}"); + try buf.appendSlice("\",\"last_validation_at\":\""); + try appendEscaped(buf, sp.last_validation_at); + try buf.appendSlice("\",\"last_validation_ok\":"); + try buf.appendSlice(if (sp.last_validation_ok) "true" else "false"); + try buf.appendSlice("}"); } pub fn nowIso8601(allocator: std.mem.Allocator) ![]const u8 { diff --git a/src/api/wizard.zig b/src/api/wizard.zig index 1d0a583..d8b5c04 100644 --- a/src/api/wizard.zig +++ b/src/api/wizard.zig @@ -661,7 +661,23 @@ pub fn handleValidateProviders( var did_save = false; for (parsed.value.providers, 0..) |prov, idx| { if (idx < probe_results.items.len and probe_results.items[idx].live_ok) { - if (!state.hasSavedProvider(prov.provider, prov.api_key, prov.model)) { + const now = providers_api.nowIso8601(allocator) catch ""; + defer if (now.len > 0) allocator.free(now); + + if (state.findSavedProviderId(prov.provider, prov.api_key, prov.model)) |existing_id| { + if (now.len > 0) { + _ = state.updateSavedProvider(existing_id, .{ + .validated_at = now, + .validated_with = component_name, + .last_validation_at = now, + .last_validation_ok = true, + }) catch { + saved_providers_warning = "validated providers could not be fully saved"; + continue; + }; + did_save = true; + } + } else { state.addSavedProvider(.{ .provider = prov.provider, .api_key = prov.api_key, @@ -671,18 +687,20 @@ pub fn handleValidateProviders( saved_providers_warning = "validated providers could not be saved"; continue; }; - // Set validated_at on the just-added provider + // Set both the last successful validation and the latest validation attempt. const providers_list = state.savedProviders(); if (providers_list.len > 0) { const new_id = providers_list[providers_list.len - 1].id; - const now = providers_api.nowIso8601(allocator) catch ""; if (now.len > 0) { - _ = state.updateSavedProvider(new_id, .{ .validated_at = now }) catch { + _ = state.updateSavedProvider(new_id, .{ + .validated_at = now, + .validated_with = component_name, + .last_validation_at = now, + .last_validation_ok = true, + }) catch { saved_providers_warning = "validated providers could not be fully saved"; - allocator.free(now); continue; }; - allocator.free(now); } } did_save = true; diff --git a/src/api_cli.zig b/src/api_cli.zig new file mode 100644 index 0000000..c8fe235 --- /dev/null +++ b/src/api_cli.zig @@ -0,0 +1,194 @@ +const std = @import("std"); +const cli = @import("cli.zig"); + +pub const ExecuteError = error{ + InvalidMethod, + InvalidTarget, +}; + +pub const Result = struct { + status: std.http.Status, + body: []u8, + + pub fn deinit(self: *Result, allocator: std.mem.Allocator) void { + allocator.free(self.body); + self.* = undefined; + } +}; + +pub fn run(allocator: std.mem.Allocator, opts: cli.ApiOptions) !void { + var result = try execute(allocator, opts); + defer result.deinit(allocator); + + const formatted = if (opts.pretty) + try prettyBody(allocator, result.body) + else + try allocator.dupe(u8, result.body); + defer allocator.free(formatted); + + if (formatted.len > 0) { + try writeAll(std.fs.File.stdout(), formatted); + if (formatted[formatted.len - 1] != '\n') { + try writeAll(std.fs.File.stdout(), "\n"); + } + } + + const code = @intFromEnum(result.status); + if (code < 200 or code >= 300) { + var buf: [64]u8 = undefined; + const line = try std.fmt.bufPrint(&buf, "HTTP {d}\n", .{code}); + try writeAll(std.fs.File.stderr(), line); + return error.RequestFailed; + } +} + +pub fn execute(allocator: std.mem.Allocator, opts: cli.ApiOptions) !Result { + const method = parseMethod(opts.method) orelse return ExecuteError.InvalidMethod; + const target = try normalizeTargetAlloc(allocator, opts.target); + defer allocator.free(target); + + const url = try std.fmt.allocPrint(allocator, "http://{s}:{d}{s}", .{ opts.host, opts.port, target }); + defer allocator.free(url); + + const request_body = try loadBodyAlloc(allocator, opts); + defer if (request_body.owned) allocator.free(request_body.bytes); + + var auth_header: ?[]u8 = null; + defer if (auth_header) |value| allocator.free(value); + + var header_storage: [2]std.http.Header = undefined; + var header_count: usize = 0; + if (request_body.bytes.len > 0) { + header_storage[header_count] = .{ .name = "Content-Type", .value = opts.content_type }; + header_count += 1; + } + if (opts.token) |token| { + auth_header = try std.fmt.allocPrint(allocator, "Bearer {s}", .{token}); + header_storage[header_count] = .{ .name = "Authorization", .value = auth_header.? }; + header_count += 1; + } + + var client: std.http.Client = .{ .allocator = allocator }; + defer client.deinit(); + + var response_body: std.io.Writer.Allocating = .init(allocator); + defer response_body.deinit(); + + const result = try client.fetch(.{ + .location = .{ .url = url }, + .method = method, + .payload = payloadForFetch(method, request_body.bytes), + .response_writer = &response_body.writer, + .extra_headers = header_storage[0..header_count], + }); + + return .{ + .status = result.status, + .body = try response_body.toOwnedSlice(), + }; +} + +fn writeAll(file: std.fs.File, bytes: []const u8) !void { + var buf: [4096]u8 = undefined; + var writer = file.writer(&buf); + try writer.interface.writeAll(bytes); + try writer.interface.flush(); +} + +fn parseMethod(raw: []const u8) ?std.http.Method { + if (std.ascii.eqlIgnoreCase(raw, "GET")) return .GET; + if (std.ascii.eqlIgnoreCase(raw, "POST")) return .POST; + if (std.ascii.eqlIgnoreCase(raw, "PUT")) return .PUT; + if (std.ascii.eqlIgnoreCase(raw, "DELETE")) return .DELETE; + if (std.ascii.eqlIgnoreCase(raw, "PATCH")) return .PATCH; + if (std.ascii.eqlIgnoreCase(raw, "HEAD")) return .HEAD; + if (std.ascii.eqlIgnoreCase(raw, "OPTIONS")) return .OPTIONS; + return null; +} + +fn normalizeTargetAlloc(allocator: std.mem.Allocator, raw: []const u8) ![]u8 { + if (raw.len == 0) return ExecuteError.InvalidTarget; + if (std.mem.startsWith(u8, raw, "http://") or std.mem.startsWith(u8, raw, "https://")) { + return ExecuteError.InvalidTarget; + } + if (raw[0] == '/') return allocator.dupe(u8, raw); + if (std.mem.startsWith(u8, raw, "api/")) return std.fmt.allocPrint(allocator, "/{s}", .{raw}); + if (std.mem.eql(u8, raw, "health")) return allocator.dupe(u8, "/health"); + return std.fmt.allocPrint(allocator, "/api/{s}", .{raw}); +} + +const LoadedBody = struct { + bytes: []const u8, + owned: bool = false, +}; + +fn loadBodyAlloc(allocator: std.mem.Allocator, opts: cli.ApiOptions) !LoadedBody { + if (opts.body_file) |path| { + if (std.mem.eql(u8, path, "-")) { + const bytes = try std.fs.File.stdin().readToEndAlloc(allocator, 8 * 1024 * 1024); + return .{ .bytes = bytes, .owned = true }; + } + const file = try std.fs.cwd().openFile(path, .{}); + defer file.close(); + const bytes = try file.readToEndAlloc(allocator, 8 * 1024 * 1024); + return .{ .bytes = bytes, .owned = true }; + } + if (opts.body) |body| return .{ .bytes = body, .owned = false }; + return .{ .bytes = "", .owned = false }; +} + +fn payloadForFetch(method: std.http.Method, body: []const u8) ?[]const u8 { + if (body.len > 0) return body; + if (method.requestHasBody()) return body; + return null; +} + +fn prettyBody(allocator: std.mem.Allocator, body: []const u8) ![]u8 { + if (body.len == 0) return allocator.dupe(u8, body); + + const parsed = std.json.parseFromSlice(std.json.Value, allocator, body, .{ + .allocate = .alloc_always, + .ignore_unknown_fields = true, + }) catch return allocator.dupe(u8, body); + defer parsed.deinit(); + + return std.json.Stringify.valueAlloc(allocator, parsed.value, .{ + .whitespace = .indent_2, + }); +} + +test "normalizeTargetAlloc keeps explicit API path" { + const value = try normalizeTargetAlloc(std.testing.allocator, "/api/status"); + defer std.testing.allocator.free(value); + try std.testing.expectEqualStrings("/api/status", value); +} + +test "normalizeTargetAlloc prefixes api namespace" { + const value = try normalizeTargetAlloc(std.testing.allocator, "instances/nullclaw/demo"); + defer std.testing.allocator.free(value); + try std.testing.expectEqualStrings("/api/instances/nullclaw/demo", value); +} + +test "normalizeTargetAlloc supports health shorthand" { + const value = try normalizeTargetAlloc(std.testing.allocator, "health"); + defer std.testing.allocator.free(value); + try std.testing.expectEqualStrings("/health", value); +} + +test "parseMethod accepts common verbs case-insensitively" { + try std.testing.expectEqual(std.http.Method.DELETE, parseMethod("delete").?); + try std.testing.expectEqual(std.http.Method.PATCH, parseMethod("PATCH").?); + try std.testing.expect(parseMethod("TRACE") == null); +} + +test "prettyBody indents JSON output" { + const value = try prettyBody(std.testing.allocator, "{\"ok\":true}"); + defer std.testing.allocator.free(value); + try std.testing.expect(std.mem.indexOf(u8, value, "\n") != null); + try std.testing.expect(std.mem.indexOf(u8, value, " \"ok\"") != null); +} + +test "payloadForFetch keeps empty body for POST" { + try std.testing.expect(payloadForFetch(.POST, "") != null); + try std.testing.expect(payloadForFetch(.GET, "") == null); +} diff --git a/src/cli.zig b/src/cli.zig index fb4fc89..3bd9f67 100644 --- a/src/cli.zig +++ b/src/cli.zig @@ -42,6 +42,18 @@ pub const WizardOptions = struct { component: []const u8, }; +pub const ApiOptions = struct { + method: []const u8, + target: []const u8, + host: []const u8 = access.default_bind_host, + port: u16 = access.default_port, + body: ?[]const u8 = null, + body_file: ?[]const u8 = null, + token: ?[]const u8 = null, + content_type: []const u8 = "application/json", + pretty: bool = false, +}; + pub const ServiceCommand = enum { install, uninstall, @@ -82,6 +94,7 @@ pub const Command = union(enum) { update_all, config: ConfigOptions, wizard: WizardOptions, + api: ApiOptions, service: ServiceCommand, uninstall: UninstallOptions, add_source: AddSourceOptions, @@ -149,6 +162,9 @@ pub fn parse(args: *std.process.ArgIterator) Command { if (std.mem.eql(u8, cmd, "wizard")) { return parseWizard(args); } + if (std.mem.eql(u8, cmd, "api")) { + return parseApi(args); + } if (std.mem.eql(u8, cmd, "service")) { return parseService(args); } @@ -278,6 +294,36 @@ fn parseService(args: *std.process.ArgIterator) Command { return .{ .service = sc }; } +fn parseApi(args: *std.process.ArgIterator) Command { + const method = args.next() orelse return .help; + const target = args.next() orelse return .help; + + var opts = ApiOptions{ + .method = method, + .target = target, + }; + while (args.next()) |arg| { + if (std.mem.eql(u8, arg, "--host")) { + if (args.next()) |val| opts.host = val; + } else if (std.mem.eql(u8, arg, "--port")) { + if (args.next()) |val| { + opts.port = std.fmt.parseInt(u16, val, 10) catch access.default_port; + } + } else if (std.mem.eql(u8, arg, "--body")) { + if (args.next()) |val| opts.body = val; + } else if (std.mem.eql(u8, arg, "--body-file")) { + if (args.next()) |val| opts.body_file = val; + } else if (std.mem.eql(u8, arg, "--token")) { + if (args.next()) |val| opts.token = val; + } else if (std.mem.eql(u8, arg, "--content-type")) { + if (args.next()) |val| opts.content_type = val; + } else if (std.mem.eql(u8, arg, "--pretty")) { + opts.pretty = true; + } + } + return .{ .api = opts }; +} + fn parseUninstall(args: *std.process.ArgIterator) Command { const arg = args.next() orelse return .help; const ref = parseInstanceRef(arg) orelse return .help; @@ -320,11 +366,18 @@ pub fn printUsage() void { \\ check-updates Check for updates \\ update Update an instance \\ update-all Update all instances + \\ api Call any local nullhub HTTP API route \\ uninstall Remove an instance \\ service Manage OS service \\ add-source Add custom component source \\ version, -v, --version Show version \\ + \\API examples: + \\ nullhub api GET /api/instances + \\ nullhub api DELETE /api/instances/nullclaw/demo + \\ nullhub api POST providers/2/validate + \\ nullhub api PATCH instances/nullclaw/demo --body '{{"auto_start":true}}' + \\ , .{}); } @@ -414,3 +467,19 @@ test "UninstallOptions defaults" { const opts = UninstallOptions{ .instance = ref }; try std.testing.expect(!opts.remove_data); } + +test "ApiOptions defaults" { + const opts = ApiOptions{ + .method = "GET", + .target = "/api/status", + }; + try std.testing.expectEqualStrings("GET", opts.method); + try std.testing.expectEqualStrings("/api/status", opts.target); + try std.testing.expectEqualStrings(access.default_bind_host, opts.host); + try std.testing.expectEqual(access.default_port, opts.port); + try std.testing.expect(opts.body == null); + try std.testing.expect(opts.body_file == null); + try std.testing.expect(opts.token == null); + try std.testing.expectEqualStrings("application/json", opts.content_type); + try std.testing.expect(!opts.pretty); +} diff --git a/src/core/state.zig b/src/core/state.zig index 1304d54..a7187c9 100644 --- a/src/core/state.zig +++ b/src/core/state.zig @@ -17,6 +17,8 @@ pub const SavedProvider = struct { model: []const u8 = "", validated_at: []const u8 = "", validated_with: []const u8 = "", + last_validation_at: []const u8 = "", + last_validation_ok: bool = false, }; pub const SavedProviderInput = struct { @@ -32,6 +34,8 @@ pub const SavedProviderUpdate = struct { model: ?[]const u8 = null, validated_at: ?[]const u8 = null, validated_with: ?[]const u8 = null, + last_validation_at: ?[]const u8 = null, + last_validation_ok: ?bool = null, }; pub const SavedChannel = struct { @@ -156,6 +160,7 @@ pub const State = struct { if (sp.model.len > 0) self.allocator.free(sp.model); if (sp.validated_at.len > 0) self.allocator.free(sp.validated_at); if (sp.validated_with.len > 0) self.allocator.free(sp.validated_with); + if (sp.last_validation_at.len > 0) self.allocator.free(sp.last_validation_at); } fn freeSavedChannelStrings(self: *State, sc: SavedChannel) void { @@ -265,6 +270,8 @@ pub const State = struct { errdefer if (owned_validated_at.len > 0) allocator.free(@constCast(owned_validated_at)); const owned_validated_with = if (sp.validated_with.len > 0) try allocator.dupe(u8, sp.validated_with) else @as([]const u8, ""); errdefer if (owned_validated_with.len > 0) allocator.free(@constCast(owned_validated_with)); + const owned_last_validation_at = if (sp.last_validation_at.len > 0) try allocator.dupe(u8, sp.last_validation_at) else @as([]const u8, ""); + errdefer if (owned_last_validation_at.len > 0) allocator.free(@constCast(owned_last_validation_at)); try state.saved_providers.append(.{ .id = sp.id, @@ -274,6 +281,8 @@ pub const State = struct { .model = owned_model, .validated_at = owned_validated_at, .validated_with = owned_validated_with, + .last_validation_at = owned_last_validation_at, + .last_validation_ok = sp.last_validation_ok, }); } @@ -489,6 +498,8 @@ pub const State = struct { .model = model, .validated_at = "", .validated_with = validated_with, + .last_validation_at = "", + .last_validation_ok = false, }); } @@ -514,7 +525,12 @@ pub const State = struct { if (validated_with.len > 0) try self.allocator.dupe(u8, validated_with) else @as([]const u8, "") else null; - // No errdefer needed for the last one - nothing after can fail + errdefer if (new_validated_with) |w| if (w.len > 0) self.allocator.free(@constCast(w)); + const new_last_validation_at = if (update.last_validation_at) |last_validation_at| + if (last_validation_at.len > 0) try self.allocator.dupe(u8, last_validation_at) else @as([]const u8, "") + else + null; + errdefer if (new_last_validation_at) |t| if (t.len > 0) self.allocator.free(@constCast(t)); // Apply all at once (no more failures possible) if (update.name != null) { @@ -542,6 +558,14 @@ pub const State = struct { if (sp.validated_with.len > 0) self.allocator.free(sp.validated_with); sp.validated_with = w; } + if (update.last_validation_at != null) { + const t = new_last_validation_at.?; + if (sp.last_validation_at.len > 0) self.allocator.free(sp.last_validation_at); + sp.last_validation_at = t; + } + if (update.last_validation_ok) |ok| { + sp.last_validation_ok = ok; + } return true; } @@ -572,6 +596,18 @@ pub const State = struct { return false; } + pub fn findSavedProviderId(self: *State, provider: []const u8, api_key: []const u8, model: []const u8) ?u32 { + for (self.saved_providers.items) |sp| { + if (std.mem.eql(u8, sp.provider, provider) and + std.mem.eql(u8, sp.api_key, api_key) and + std.mem.eql(u8, sp.model, model)) + { + return sp.id; + } + } + return null; + } + // ─── SavedChannel CRUD ────────────────────────────────────────────── pub fn savedChannels(self: *State) []const SavedChannel { @@ -1171,6 +1207,42 @@ test "update saved provider clears model" { try std.testing.expectEqualStrings("", providers[0].model); } +test "saved provider validation metadata persists through update and load" { + const allocator = std.testing.allocator; + const path = try testPath(allocator, "state.json"); + defer allocator.free(path); + defer cleanupTestDir(); + + { + var s = State.init(allocator, path); + defer s.deinit(); + + try s.addSavedProvider(.{ + .provider = "openrouter", + .api_key = "key1", + .validated_with = "nullclaw", + }); + const updated = try s.updateSavedProvider(1, .{ + .validated_at = "2026-03-11T18:59:00Z", + .validated_with = "nullclaw", + .last_validation_at = "2026-03-14T11:22:33Z", + .last_validation_ok = false, + }); + try std.testing.expect(updated); + try s.save(); + } + + { + var s = try State.load(allocator, path); + defer s.deinit(); + + const provider = s.getSavedProvider(1).?; + try std.testing.expectEqualStrings("2026-03-11T18:59:00Z", provider.validated_at); + try std.testing.expectEqualStrings("2026-03-14T11:22:33Z", provider.last_validation_at); + try std.testing.expect(!provider.last_validation_ok); + } +} + test "remove saved provider" { const allocator = std.testing.allocator; const path = try testPath(allocator, "state.json"); diff --git a/src/main.zig b/src/main.zig index f83ec76..de236ad 100644 --- a/src/main.zig +++ b/src/main.zig @@ -2,6 +2,7 @@ const std = @import("std"); const builtin = @import("builtin"); pub const root = @import("root.zig"); const cli = root.cli; +const api_cli = root.api_cli; const server = root.server; const service = root.service; const paths_mod = root.paths; @@ -61,6 +62,18 @@ pub fn main() !void { try srv.run(); }, .status => |opts| try status_cli.run(allocator, opts), + .api => |opts| api_cli.run(allocator, opts) catch |err| { + const any_err: anyerror = err; + switch (any_err) { + error.InvalidMethod => std.debug.print("Invalid HTTP method: {s}\n", .{opts.method}), + error.InvalidTarget => std.debug.print("Invalid API target: {s}\n", .{opts.target}), + error.FileNotFound => std.debug.print("Body file not found.\n", .{}), + error.ConnectionRefused => std.debug.print("nullhub is not running on http://{s}:{d}\n", .{ opts.host, opts.port }), + error.RequestFailed => {}, + else => std.debug.print("API request failed: {s}\n", .{@errorName(any_err)}), + } + std.process.exit(1); + }, .install => |opts| { std.debug.print("install {s}", .{opts.component}); if (opts.name) |n| std.debug.print(" --name {s}", .{n}); diff --git a/src/root.zig b/src/root.zig index 1c2c9e3..e5f6cd0 100644 --- a/src/root.zig +++ b/src/root.zig @@ -1,5 +1,6 @@ pub const auth = @import("auth.zig"); pub const access = @import("access.zig"); +pub const api_cli = @import("api_cli.zig"); pub const builder = @import("installer/builder.zig"); pub const cli = @import("cli.zig"); pub const component_cli = @import("core/component_cli.zig"); @@ -39,6 +40,7 @@ pub const usage_api = @import("api/usage.zig"); test { _ = auth; _ = access; + _ = api_cli; _ = builder; _ = cli; _ = component_cli; diff --git a/ui/src/lib/components/Sidebar.svelte b/ui/src/lib/components/Sidebar.svelte index e66b77d..d0b3d10 100644 --- a/ui/src/lib/components/Sidebar.svelte +++ b/ui/src/lib/components/Sidebar.svelte @@ -5,18 +5,30 @@ import { orchestrationUiRoutes } from "$lib/orchestration/routes"; let instances = $state>({}); + let installedComponents = $state>({}); let currentPath = $derived($page.url.pathname); + let showOrchestration = $derived(Boolean(installedComponents["nullboiler"]?.installed)); - async function loadInstances() { - try { - const status = await api.getStatus(); - instances = status.instances || {}; - } catch {} + async function loadSidebarState() { + const [statusResult, componentsResult] = await Promise.allSettled([ + api.getStatus(), + api.getComponents(), + ]); + + if (statusResult.status === "fulfilled") { + instances = statusResult.value.instances || {}; + } + + if (componentsResult.status === "fulfilled") { + installedComponents = Object.fromEntries( + (componentsResult.value.components || []).map((component: any) => [component.name, component]), + ); + } } onMount(() => { - loadInstances(); - const interval = setInterval(loadInstances, 5000); + void loadSidebarState(); + const interval = setInterval(loadSidebarState, 5000); return () => clearInterval(interval); }); @@ -53,13 +65,15 @@ {/each} - + {#if showOrchestration} + + {/if} {:else} + {@const indicator = providerIndicatorState(p)}
- +

{p.name}

{getProviderLabel(p.provider)} @@ -273,10 +298,17 @@
{#if p.validated_at}
- Validated + Last Successful Validation {formatDate(p.validated_at)}
{/if} +
+ Last Validation + {formatDate(lastValidationAt(p)) || "Never"} +
+ {#if !lastValidationAt(p)} +
Not validated yet. Use Re-validate to run a live auth check.
+ {/if}