skill_web/pages/
dashboard.rs

1//! Dashboard page - main overview with real-time statistics
2
3use 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
16/// Convert API skill summary to store skill summary
17fn 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
36/// Convert API execution entry to store execution entry
37fn 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/// Dashboard page component
61#[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    // Create API client
69    let api = use_memo((), |_| Rc::new(Api::new()));
70
71    // Load data on mount
72    {
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            // Set loading states
79            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                // Load skills
88                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                // Load execution history
100                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    // Calculate statistics
115    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    // Get recent executions (last 5)
121    let recent_executions: Vec<&ExecutionEntry> =
122        executions_store.history.iter().take(5).collect();
123
124    // Loading state
125    let is_loading = skills_store.loading || executions_store.loading;
126
127    // Error state
128    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            // Page header
136            <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            // Error alert
152            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            // Stats grid
164            <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            // Quick actions and recent activity
201            <div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
202                // Quick Actions
203                <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                // Recent Activity
259                <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/// Activity item props
304#[derive(Properties, PartialEq)]
305struct ActivityItemProps {
306    skill: String,
307    tool: String,
308    status: ExecutionStatus,
309    time: String,
310    duration_ms: u64,
311}
312
313/// Activity item component
314#[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    // Format duration
346    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    // Format time (simple relative time approximation)
355    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
376/// Format a timestamp as relative time (simplified)
377fn format_relative_time(timestamp: &str) -> String {
378    // For now, just return the timestamp
379    // In a real app, we'd parse and calculate the difference
380    if timestamp.len() > 16 {
381        timestamp[..16].replace('T', " ")
382    } else {
383        timestamp.to_string()
384    }
385}