skill_web/pages/
dashboard.rs1use std::rc::Rc;
4use wasm_bindgen_futures::spawn_local;
5use yew::prelude::*;
6use yew_router::prelude::*;
7use yewdux::prelude::*;
8
9use crate::api::{Api, ExecutionHistoryEntry as ApiExecutionEntry, SkillSummary as ApiSkillSummary};
10use crate::components::card::{Card, StatCard, Trend};
11use crate::components::icons::{CheckIcon, LightningIcon, PlayIcon, SkillsIcon};
12use crate::router::Route;
13use crate::store::executions::{ExecutionEntry, ExecutionStatus, ExecutionsAction, ExecutionsStore};
14use crate::store::skills::{SkillRuntime, SkillStatus, SkillSummary, SkillsAction, SkillsStore};
15
16fn api_to_store_skill(api: ApiSkillSummary) -> SkillSummary {
18 SkillSummary {
19 name: api.name,
20 version: api.version,
21 description: api.description,
22 source: api.source,
23 runtime: match api.runtime.as_str() {
24 "docker" => SkillRuntime::Docker,
25 "native" => SkillRuntime::Native,
26 _ => SkillRuntime::Wasm,
27 },
28 tools_count: api.tools_count,
29 instances_count: api.instances_count,
30 status: SkillStatus::Configured,
31 last_used: api.last_used,
32 execution_count: api.execution_count,
33 }
34}
35
36fn api_to_store_execution(api: ApiExecutionEntry) -> ExecutionEntry {
38 ExecutionEntry {
39 id: api.id,
40 skill: api.skill,
41 tool: api.tool,
42 instance: api.instance,
43 status: match api.status {
44 crate::api::ExecutionStatus::Pending => ExecutionStatus::Pending,
45 crate::api::ExecutionStatus::Running => ExecutionStatus::Running,
46 crate::api::ExecutionStatus::Success => ExecutionStatus::Success,
47 crate::api::ExecutionStatus::Failed => ExecutionStatus::Failed,
48 crate::api::ExecutionStatus::Timeout => ExecutionStatus::Timeout,
49 crate::api::ExecutionStatus::Cancelled => ExecutionStatus::Cancelled,
50 },
51 args: std::collections::HashMap::new(),
52 output: None,
53 error: api.error,
54 duration_ms: api.duration_ms,
55 started_at: api.started_at,
56 metadata: std::collections::HashMap::new(),
57 }
58}
59
60#[function_component(DashboardPage)]
62pub fn dashboard_page() -> Html {
63 let skills_store = use_store_value::<SkillsStore>();
64 let skills_dispatch = use_dispatch::<SkillsStore>();
65 let executions_store = use_store_value::<ExecutionsStore>();
66 let executions_dispatch = use_dispatch::<ExecutionsStore>();
67
68 let api = use_memo((), |_| Rc::new(Api::new()));
70
71 {
73 let api = api.clone();
74 let skills_dispatch = skills_dispatch.clone();
75 let executions_dispatch = executions_dispatch.clone();
76
77 use_effect_with((), move |_| {
78 skills_dispatch.apply(SkillsAction::SetLoading(true));
80 executions_dispatch.apply(ExecutionsAction::SetLoading(true));
81
82 let api = api.clone();
83 let skills_dispatch = skills_dispatch.clone();
84 let executions_dispatch = executions_dispatch.clone();
85
86 spawn_local(async move {
87 match api.skills.list_all().await {
89 Ok(skills) => {
90 let store_skills: Vec<SkillSummary> =
91 skills.into_iter().map(api_to_store_skill).collect();
92 skills_dispatch.apply(SkillsAction::SetSkills(store_skills));
93 }
94 Err(e) => {
95 skills_dispatch.apply(SkillsAction::SetError(Some(e.to_string())));
96 }
97 }
98
99 match api.executions.list_all_history().await {
101 Ok(history) => {
102 let store_history: Vec<ExecutionEntry> =
103 history.into_iter().map(api_to_store_execution).collect();
104 executions_dispatch.apply(ExecutionsAction::SetHistory(store_history));
105 }
106 Err(e) => {
107 executions_dispatch.apply(ExecutionsAction::SetError(Some(e.to_string())));
108 }
109 }
110 });
111 });
112 }
113
114 let skill_count = skills_store.skills.len();
116 let execution_count = executions_store.history.len();
117 let success_rate = executions_store.success_rate();
118 let success_rate_str = format!("{:.1}%", success_rate * 100.0);
119
120 let recent_executions: Vec<&ExecutionEntry> =
122 executions_store.history.iter().take(5).collect();
123
124 let is_loading = skills_store.loading || executions_store.loading;
126
127 let error = skills_store
129 .error
130 .clone()
131 .or_else(|| executions_store.error.clone());
132
133 html! {
134 <div class="space-y-6 animate-fade-in">
135 <div class="flex items-center justify-between">
137 <div>
138 <h1 class="text-2xl font-bold text-gray-900 dark:text-white">
139 { "Dashboard" }
140 </h1>
141 <p class="text-gray-500 dark:text-gray-400 mt-1">
142 { "Overview of your Skill Engine" }
143 </p>
144 </div>
145 <Link<Route> to={Route::Run} classes="btn btn-primary">
146 <PlayIcon class="w-4 h-4 mr-2" />
147 { "Run Skill" }
148 </Link<Route>>
149 </div>
150
151 if let Some(err) = error {
153 <div class="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4">
154 <div class="flex items-center gap-3">
155 <svg class="w-5 h-5 text-red-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
156 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
157 </svg>
158 <p class="text-sm text-red-700 dark:text-red-300">{ err }</p>
159 </div>
160 </div>
161 }
162
163 <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
165 <StatCard
166 title="Total Skills"
167 value={if is_loading { "--".to_string() } else { skill_count.to_string() }}
168 subtitle={format!("{} tools available", skills_store.skills.iter().map(|s| s.tools_count).sum::<usize>())}
169 icon={html! { <SkillsIcon class="w-6 h-6 text-primary-600" /> }}
170 />
171 <StatCard
172 title="Total Executions"
173 value={if is_loading { "--".to_string() } else { execution_count.to_string() }}
174 subtitle="All time"
175 icon={html! { <PlayIcon class="w-6 h-6 text-primary-600" /> }}
176 />
177 <StatCard
178 title="Success Rate"
179 value={if is_loading { "--".to_string() } else { success_rate_str }}
180 subtitle="All executions"
181 icon={html! { <CheckIcon class="w-6 h-6 text-success-500" /> }}
182 trend={if success_rate >= 0.95 {
183 Some(Trend::Up("Excellent".to_string()))
184 } else if success_rate >= 0.80 {
185 Some(Trend::Neutral("Good".to_string()))
186 } else if execution_count > 0 {
187 Some(Trend::Down("Needs attention".to_string()))
188 } else {
189 None
190 }}
191 />
192 <StatCard
193 title="Search Ready"
194 value="RAG"
195 subtitle="FastEmbed active"
196 icon={html! { <LightningIcon class="w-6 h-6 text-warning-500" /> }}
197 />
198 </div>
199
200 <div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
202 <Card title="Quick Actions">
204 <div class="space-y-3">
205 <Link<Route>
206 to={Route::Skills}
207 classes="flex items-center gap-3 p-3 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors"
208 >
209 <div class="p-2 bg-primary-50 dark:bg-primary-900/30 rounded-lg">
210 <SkillsIcon class="w-5 h-5 text-primary-600 dark:text-primary-400" />
211 </div>
212 <div class="flex-1">
213 <p class="font-medium text-gray-900 dark:text-white">
214 { "Browse Skills" }
215 </p>
216 <p class="text-sm text-gray-500 dark:text-gray-400">
217 { format!("View and manage {} installed skills", skill_count) }
218 </p>
219 </div>
220 </Link<Route>>
221
222 <Link<Route>
223 to={Route::Run}
224 classes="flex items-center gap-3 p-3 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors"
225 >
226 <div class="p-2 bg-success-50 dark:bg-green-900/30 rounded-lg">
227 <PlayIcon class="w-5 h-5 text-success-600 dark:text-green-400" />
228 </div>
229 <div class="flex-1">
230 <p class="font-medium text-gray-900 dark:text-white">
231 { "Execute Tool" }
232 </p>
233 <p class="text-sm text-gray-500 dark:text-gray-400">
234 { "Run a skill tool with parameters" }
235 </p>
236 </div>
237 </Link<Route>>
238
239 <Link<Route>
240 to={Route::Settings}
241 classes="flex items-center gap-3 p-3 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors"
242 >
243 <div class="p-2 bg-gray-100 dark:bg-gray-700 rounded-lg">
244 <LightningIcon class="w-5 h-5 text-gray-600 dark:text-gray-400" />
245 </div>
246 <div class="flex-1">
247 <p class="font-medium text-gray-900 dark:text-white">
248 { "Configure Search" }
249 </p>
250 <p class="text-sm text-gray-500 dark:text-gray-400">
251 { "Tune RAG pipeline settings" }
252 </p>
253 </div>
254 </Link<Route>>
255 </div>
256 </Card>
257
258 <Card title="Recent Activity">
260 if is_loading {
261 <div class="flex items-center justify-center py-8">
262 <div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
263 </div>
264 } else if recent_executions.is_empty() {
265 <div class="text-center py-8">
266 <PlayIcon class="w-12 h-12 text-gray-300 dark:text-gray-600 mx-auto mb-3" />
267 <p class="text-gray-500 dark:text-gray-400">
268 { "No executions yet" }
269 </p>
270 <p class="text-sm text-gray-400 dark:text-gray-500 mt-1">
271 { "Run a skill to see activity here" }
272 </p>
273 </div>
274 } else {
275 <div class="space-y-4">
276 { for recent_executions.iter().map(|entry| {
277 html! {
278 <ActivityItem
279 skill={entry.skill.clone()}
280 tool={entry.tool.clone()}
281 status={entry.status.clone()}
282 time={entry.started_at.clone()}
283 duration_ms={entry.duration_ms}
284 />
285 }
286 })}
287 </div>
288 }
289 <div class="mt-4 pt-4 border-t border-gray-200 dark:border-gray-700">
290 <Link<Route>
291 to={Route::History}
292 classes="text-sm text-primary-600 dark:text-primary-400 hover:underline"
293 >
294 { "View all activity →" }
295 </Link<Route>>
296 </div>
297 </Card>
298 </div>
299 </div>
300 }
301}
302
303#[derive(Properties, PartialEq)]
305struct ActivityItemProps {
306 skill: String,
307 tool: String,
308 status: ExecutionStatus,
309 time: String,
310 duration_ms: u64,
311}
312
313#[function_component(ActivityItem)]
315fn activity_item(props: &ActivityItemProps) -> Html {
316 let (status_class, status_icon) = match props.status {
317 ExecutionStatus::Success => (
318 "status-dot-success",
319 html! { <CheckIcon class="w-4 h-4 text-success-500" /> },
320 ),
321 ExecutionStatus::Failed | ExecutionStatus::Timeout => (
322 "status-dot-error",
323 html! {
324 <svg class="w-4 h-4 text-error-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
325 <path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
326 </svg>
327 },
328 ),
329 ExecutionStatus::Running | ExecutionStatus::Pending => (
330 "status-dot-warning",
331 html! {
332 <div class="animate-spin rounded-full h-4 w-4 border-b-2 border-warning-500"></div>
333 },
334 ),
335 ExecutionStatus::Cancelled => (
336 "status-dot-neutral",
337 html! {
338 <svg class="w-4 h-4 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
339 <path stroke-linecap="round" stroke-linejoin="round" d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636" />
340 </svg>
341 },
342 ),
343 };
344
345 let duration_str = if props.duration_ms < 1000 {
347 format!("{}ms", props.duration_ms)
348 } else if props.duration_ms < 60000 {
349 format!("{:.1}s", props.duration_ms as f64 / 1000.0)
350 } else {
351 format!("{:.1}m", props.duration_ms as f64 / 60000.0)
352 };
353
354 let time_str = format_relative_time(&props.time);
356
357 html! {
358 <div class="flex items-center gap-3">
359 <span class={classes!("status-dot", status_class)} />
360 <div class="flex-1 min-w-0">
361 <p class="text-sm font-medium text-gray-900 dark:text-white truncate">
362 { format!("{}:{}", props.skill, props.tool) }
363 </p>
364 <p class="text-xs text-gray-500 dark:text-gray-400">
365 { time_str }
366 </p>
367 </div>
368 <div class="flex items-center gap-2">
369 <span class="text-xs text-gray-400">{ duration_str }</span>
370 { status_icon }
371 </div>
372 </div>
373 }
374}
375
376fn format_relative_time(timestamp: &str) -> String {
378 if timestamp.len() > 16 {
381 timestamp[..16].replace('T', " ")
382 } else {
383 timestamp.to_string()
384 }
385}