From 8768c080d9ba3b0596fae241e44bfb4d259e4c89 Mon Sep 17 00:00:00 2001 From: KinjiKawaguchi Date: Sun, 2 Aug 2026 18:26:19 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat(demo):=20=E9=9D=99=E7=9A=84=E3=81=AA?= =?UTF-8?q?=E3=83=87=E3=83=A2=E3=83=9A=E3=83=BC=E3=82=B8=E3=81=AE=E7=94=9F?= =?UTF-8?q?=E6=88=90=E5=99=A8=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 画面の出力はJavaScriptも外部リソースも含まない自己完結したHTMLなので、 静的ホスティングにそのまま置ける。架空の予約を積んだ画面を起動して期間ごとの HTMLを保存する。 期間切り替えのリンクはサーバー上の絶対パスで、配信先がドメイン直下とは限らない 静的ホスティングでは行き先を見失う。ファイル名へ置き換えたうえで、絶対パスが 残っていないことをその場で確かめる。 所有者名には英字の定番名を使い、架空のデータである旨をページ先頭に出す。 公開されるページで実在の利用者と読み違えられないようにするため。 開発中に画面を目で確かめる用途も兼ねるので、tests/preview.rs は役目を終えて削除する。 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016uSBf6WpYKseYkhxDLKCLt --- Cargo.toml | 5 + examples/demo_page.rs | 269 ++++++++++++++++++++++++++++++++++++++++++ tests/preview.rs | 174 --------------------------- 3 files changed, 274 insertions(+), 174 deletions(-) create mode 100644 examples/demo_page.rs delete mode 100644 tests/preview.rs diff --git a/Cargo.toml b/Cargo.toml index eda680f..c8880a5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -60,3 +60,8 @@ default = [] # 予約タイムラインのWeb画面。crates.io経由のライブラリ利用者に # Topcoatの依存を負わせないため、既定では無効にする。 web = ["dep:topcoat"] + +# Web画面の静的なデモを書き出す。GitHub Pagesへの配信に使う +[[example]] +name = "demo_page" +required-features = ["web"] diff --git a/examples/demo_page.rs b/examples/demo_page.rs new file mode 100644 index 0000000..a195ce4 --- /dev/null +++ b/examples/demo_page.rs @@ -0,0 +1,269 @@ +//! タイムライン画面の静的なデモを書き出す +//! +//! 架空の予約を積んだ画面を起動し、期間ごとのHTMLを取得して保存する。生成物は +//! JavaScriptも外部リソースも含まない自己完結したHTMLなので、GitHub Pagesのような +//! 静的ホスティングにそのまま置ける。 +//! +//! 開発中に画面を目で確かめたいときにも使える。 +//! +//! ```bash +//! cargo run --example demo_page --features web -- dist +//! ``` + +use chrono::{Duration, Utc}; +use lab_resource_manager::application::usecases::list_all_future_resource_usages::ListAllFutureResourceUsagesUseCase; +use lab_resource_manager::domain::aggregates::resource_usage::entity::ResourceUsage; +use lab_resource_manager::domain::aggregates::resource_usage::value_objects::{ + Gpu, Resource, TimePeriod, +}; +use lab_resource_manager::domain::common::EmailAddress; +use lab_resource_manager::domain::ports::repositories::ResourceUsageRepository; +use lab_resource_manager::infrastructure::config::{ + DeviceConfig, ResourceConfig, RoomConfig, ServerConfig, +}; +use lab_resource_manager::infrastructure::repositories::resource_usage::mock::MockUsageRepository; +use lab_resource_manager::interface::web::{ + self, + query::{ReservationQuery, UseCaseReservationQuery}, +}; +use std::path::Path; +use std::sync::Arc; + +/// 書き出す表示期間。画面の期間切り替えが持つ選択肢と揃える +const DAYS: [i64; 4] = [1, 3, 7, 30]; + +/// 入口として複製する期間 +const LANDING_DAYS: i64 = 7; + +/// 積む予約の指定(所有者, 開始, 終了, リソース, 備考) +/// +/// 開始と終了は現在時刻からの時間数で表す。所有者は実在の人物と読み違えられないよう +/// 英字の定番名を使う。 +type Sample = (&'static str, i64, i64, Vec, Option<&'static str>); + +fn gpu(server: &str, device: u32, model: &str) -> Resource { + Resource::Gpu(Gpu::new(server.to_string(), device, model.to_string())) +} + +fn room(name: &str) -> Resource { + Resource::Room { + name: name.to_string(), + } +} + +fn server(name: &str, model: &str, count: u32) -> ServerConfig { + ServerConfig { + name: name.to_string(), + calendar_id: format!("{}@example.com", name.to_lowercase()), + devices: (0..count) + .map(|id| DeviceConfig { + id, + model: model.to_string(), + }) + .collect(), + notifications: vec![], + } +} + +fn demo_config() -> ResourceConfig { + ResourceConfig { + servers: vec![ + server("Aurora", "A100", 4), + server("Borealis", "RTX6000", 2), + ], + rooms: vec![ + RoomConfig { + name: "会議室".to_string(), + calendar_id: "meeting@example.com".to_string(), + notifications: vec![], + }, + RoomConfig { + name: "輪読室".to_string(), + calendar_id: "seminar@example.com".to_string(), + notifications: vec![], + }, + ], + } +} + +/// 画面の見どころが一通り出るように予約を選んである +fn demo_reservations() -> Vec { + vec![ + // 表示範囲の前から続く、進行中の予約 + ( + "alice", + -30, + 18, + vec![gpu("Aurora", 0, "A100"), gpu("Aurora", 1, "A100")], + Some("事前学習"), + ), + ("bob", 2, 26, vec![gpu("Aurora", 2, "A100")], Some("蒸留")), + ("carol", 30, 54, vec![gpu("Aurora", 3, "A100")], None), + // 同じGPUで時間が重なる二件。上下に段が分かれる + ( + "dave", + 6, + 40, + vec![gpu("Borealis", 0, "RTX6000")], + Some("評価"), + ), + ( + "erin", + 20, + 60, + vec![gpu("Borealis", 0, "RTX6000")], + Some("再現実験"), + ), + ( + "frank", + 50, + 120, + vec![gpu("Borealis", 1, "RTX6000")], + Some("長時間ジョブ"), + ), + ("grace", 4, 6, vec![room("会議室")], Some("ゼミ")), + ("heidi", 28, 30, vec![room("会議室")], Some("研究会")), + ("ivan", 52, 55, vec![room("輪読室")], None), + // 設定から外れたサーバーの予約。末尾にまとめて出る + ( + "judy", + 10, + 20, + vec![gpu("Retired", 0, "K80")], + Some("撤去済みサーバーの予約"), + ), + ] +} + +/// 架空のデータであることを画面の先頭で断る +/// +/// Tailwindはビルド時にクラス名を走査するため、生成済みのCSSに無いクラスは効かない。 +/// ここは後から挿し込む断り書きなので、素のスタイル指定で完結させる。 +const NOTICE: &str = concat!( + r#"
"#, + "これは lab-resource-manager の予約タイムラインのデモです。", + "表示されている予約・利用者・リソースはすべて架空のものです。", + r#" リポジトリ
"#, +); + +/// 静的ホスティング向けにHTMLを整える +/// +/// 画面が出すリンクはサーバー上の絶対パス(`/?days=7`)で、配信先が +/// ドメイン直下とは限らない静的ホスティングでは行き先を見失う。 +/// 期間ごとのファイル名へ置き換える。 +fn to_static_page(html: &str) -> String { + let mut page = html.to_string(); + + for days in DAYS { + // 属性ごと置き換える。`/?days=3`だけを狙うと`/?days=30`の先頭にも当たり、 + // 30日表示へのリンクが`3days.html0`に化ける + page = page.replace( + &format!(r#"href="/?days={}""#, days), + &format!(r#"href="{}""#, page_name(days)), + ); + } + + let page = page.replace( + r#""#, + &format!(r#"{}"#, NOTICE), + ); + + // 行き先を見失うリンクを配信してしまわないための歯止め + assert!( + !page.contains(r#"href="/?"#), + "サーバー上の絶対パスを指すリンクが残っている" + ); + + page +} + +fn page_name(days: i64) -> String { + format!("{}days.html", days) +} + +/// 架空の予約を積んだ画面を起動し、基準URLを返す +async fn start_demo_server() -> String { + let repository = Arc::new(MockUsageRepository::new()); + let now = Utc::now(); + + for (owner, from_hours, to_hours, resources, notes) in demo_reservations() { + let usage = ResourceUsage::new( + EmailAddress::new(format!("{}@example.com", owner)).expect("メールアドレスの形式"), + TimePeriod::new( + now + Duration::hours(from_hours), + now + Duration::hours(to_hours), + ) + .expect("開始は終了より前"), + resources, + notes.map(str::to_string), + ) + .expect("リソースが1つ以上ある"); + + repository + .save(&usage) + .await + .expect("モックの保存は失敗しない"); + } + + let query: Arc = Arc::new(UseCaseReservationQuery::new(Arc::new( + ListAllFutureResourceUsagesUseCase::new(repository), + ))); + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("空きポートに束縛できる"); + let addr = listener.local_addr().expect("束縛したアドレスを取得できる"); + + tokio::spawn(async move { + web::serve_on( + listener, + query, + Arc::new(demo_config()), + chrono_tz::Asia::Tokyo, + ) + .await + .expect("デモサーバーが起動する"); + }); + + format!("http://{}", addr) +} + +#[tokio::main] +async fn main() { + let out = std::env::args() + .nth(1) + .unwrap_or_else(|| "dist".to_string()); + let out = Path::new(&out); + + std::fs::create_dir_all(out).expect("出力先を作成できる"); + + let base = start_demo_server().await; + let mut landing = None; + + for days in DAYS { + let html = reqwest::get(format!("{}/?days={}", base, days)) + .await + .expect("デモサーバーに接続できる") + .text() + .await + .expect("本文を読める"); + + let page = to_static_page(&html); + + if days == LANDING_DAYS { + landing = Some(page.clone()); + } + + let path = out.join(page_name(days)); + std::fs::write(&path, page).expect("HTMLを書き出せる"); + println!("{}", path.display()); + } + + // 入口。期間切り替えのリンクは相対パスなので、複製しても行き先は変わらない + let index = out.join("index.html"); + std::fs::write(&index, landing.expect("入口にする期間が書き出されている")) + .expect("index.htmlを書き出せる"); + println!("{}", index.display()); +} diff --git a/tests/preview.rs b/tests/preview.rs deleted file mode 100644 index 88fc773..0000000 --- a/tests/preview.rs +++ /dev/null @@ -1,174 +0,0 @@ -#![cfg(feature = "web")] - -use chrono::{Duration, Utc}; -use lab_resource_manager::application::usecases::list_all_future_resource_usages::ListAllFutureResourceUsagesUseCase; -use lab_resource_manager::domain::aggregates::resource_usage::entity::ResourceUsage; -use lab_resource_manager::domain::aggregates::resource_usage::value_objects::{ - Gpu, Resource, TimePeriod, -}; -use lab_resource_manager::domain::common::EmailAddress; -use lab_resource_manager::domain::ports::repositories::ResourceUsageRepository; -use lab_resource_manager::infrastructure::config::{ - DeviceConfig, ResourceConfig, RoomConfig, ServerConfig, -}; -use lab_resource_manager::infrastructure::repositories::resource_usage::mock::MockUsageRepository; -use lab_resource_manager::interface::web::{ - self, - query::{ReservationQuery, UseCaseReservationQuery}, -}; -use std::sync::Arc; - -/// 積む予約の指定(所有者, 開始, 終了, リソース, 備考) -/// -/// 開始と終了は現在時刻からの時間数で表す。 -type Sample = (&'static str, i64, i64, Vec, Option<&'static str>); - -fn server(name: &str, model: &str, count: u32) -> ServerConfig { - ServerConfig { - name: name.to_string(), - calendar_id: format!("{}@example.com", name.to_lowercase()), - devices: (0..count) - .map(|id| DeviceConfig { - id, - model: model.to_string(), - }) - .collect(), - notifications: vec![], - } -} - -fn room(name: &str) -> RoomConfig { - RoomConfig { - name: name.to_string(), - calendar_id: format!("{}@example.com", name), - notifications: vec![], - } -} - -#[tokio::test] -#[ignore = "画面を目で確かめるためのHTMLを書き出す"] -async fn write_preview_html() { - let config = ResourceConfig { - servers: vec![server("Thalys", "A100", 4), server("Freccia", "RTX6000", 2)], - rooms: vec![room("会議室"), room("輪読室")], - }; - - let repository = Arc::new(MockUsageRepository::new()); - let now = Utc::now(); - - let gpu = |server: &str, device: u32, model: &str| { - Resource::Gpu(Gpu::new(server.to_string(), device, model.to_string())) - }; - - let reservations: Vec = vec![ - // 進行中で、表示範囲の前から続いている - ( - "kawaguchi", - -30, - 18, - vec![gpu("Thalys", 0, "A100"), gpu("Thalys", 1, "A100")], - Some("事前学習"), - ), - ("sato", 2, 26, vec![gpu("Thalys", 2, "A100")], Some("蒸留")), - ("suzuki", 30, 54, vec![gpu("Thalys", 3, "A100")], None), - // 同じGPUで時間が重なる(レーンが分かれる) - ( - "tanaka", - 6, - 40, - vec![gpu("Freccia", 0, "RTX6000")], - Some("評価"), - ), - ( - "takahashi", - 20, - 60, - vec![gpu("Freccia", 0, "RTX6000")], - Some("再現実験"), - ), - ( - "ito", - 50, - 120, - vec![gpu("Freccia", 1, "RTX6000")], - Some("長時間ジョブ"), - ), - ( - "watanabe", - 4, - 6, - vec![Resource::Room { - name: "会議室".to_string(), - }], - Some("ゼミ"), - ), - ( - "yamamoto", - 28, - 30, - vec![Resource::Room { - name: "会議室".to_string(), - }], - Some("研究会"), - ), - ( - "nakamura", - 52, - 55, - vec![Resource::Room { - name: "輪読室".to_string(), - }], - None, - ), - // 設定に無いサーバー - ( - "obsolete", - 10, - 20, - vec![gpu("Retired", 0, "K80")], - Some("撤去済みサーバーの予約"), - ), - ]; - - for (owner, from_hours, to_hours, resources, notes) in reservations { - let usage = ResourceUsage::new( - EmailAddress::new(format!("{}@example.com", owner)).unwrap(), - TimePeriod::new( - now + Duration::hours(from_hours), - now + Duration::hours(to_hours), - ) - .unwrap(), - resources, - notes.map(|n| n.to_string()), - ) - .unwrap(); - - repository.save(&usage).await.unwrap(); - } - - let query: Arc = Arc::new(UseCaseReservationQuery::new(Arc::new( - ListAllFutureResourceUsagesUseCase::new(repository), - ))); - - let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - let addr = listener.local_addr().unwrap(); - - tokio::spawn(async move { - web::serve_on(listener, query, Arc::new(config), chrono_tz::Asia::Tokyo) - .await - .unwrap(); - }); - - let out = std::env::var("PREVIEW_OUT").expect("PREVIEW_OUTに出力先を指定してください"); - - for days in [3, 7] { - let body = reqwest::get(format!("http://{}/?days={}", addr, days)) - .await - .unwrap() - .text() - .await - .unwrap(); - - std::fs::write(format!("{}/timeline-{}days.html", out, days), body).unwrap(); - } -} From 4cc0c409937db77073b64ff1a3fedd32d98dc943 Mon Sep 17 00:00:00 2001 From: KinjiKawaguchi Date: Sun, 2 Aug 2026 18:26:19 +0900 Subject: [PATCH 2/3] =?UTF-8?q?ci:=20=E3=83=87=E3=83=A2=E3=83=9A=E3=83=BC?= =?UTF-8?q?=E3=82=B8=E3=82=92GitHub=20Pages=E3=81=B8=E9=85=8D=E4=BF=A1?= =?UTF-8?q?=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 画面には現在時刻の目印が入るため、作り直さないと日付が置き去りになる。 mainへの反映時に加えて毎朝更新する。 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016uSBf6WpYKseYkhxDLKCLt --- .github/workflows/demo-page.yml | 58 +++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 .github/workflows/demo-page.yml diff --git a/.github/workflows/demo-page.yml b/.github/workflows/demo-page.yml new file mode 100644 index 0000000..3f04f1f --- /dev/null +++ b/.github/workflows/demo-page.yml @@ -0,0 +1,58 @@ +name: Demo Page + +on: + push: + branches: + - main + # 画面には現在時刻の目印が入る。作り直さないと日付が置き去りになるため毎朝更新する + schedule: + - cron: "0 21 * * *" + workflow_dispatch: + +env: + CARGO_TERM_COLOR: always + RUST_BACKTRACE: 1 + +permissions: + contents: read + pages: write + id-token: write + +# 配信は最新の1つだけでよい。進行中のデプロイは中断せず待たせる +concurrency: + group: pages + cancel-in-progress: false + +jobs: + build: + name: Build demo page + runs-on: ubuntu-latest + + steps: + - name: Fetch Repository + uses: actions/checkout@v5 + + - name: Install stable toolchain + uses: actions-rust-lang/setup-rust-toolchain@v1 + + - name: Generate static pages + run: cargo run --release --features web --example demo_page -- dist + + - name: Upload artifact + uses: actions/upload-pages-artifact@v3 + with: + path: dist + + deploy: + name: Deploy to GitHub Pages + needs: build + runs-on: ubuntu-latest + + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + + steps: + - name: Deploy + id: deployment + uses: actions/deploy-pages@v4 From 3b1f636165db773e4d133099c27c8a8ad12a018f Mon Sep 17 00:00:00 2001 From: KinjiKawaguchi Date: Sun, 2 Aug 2026 18:26:19 +0900 Subject: [PATCH 3/3] =?UTF-8?q?docs:=20=E3=83=87=E3=83=A2=E3=83=9A?= =?UTF-8?q?=E3=83=BC=E3=82=B8=E3=81=B8=E3=81=AE=E3=83=AA=E3=83=B3=E3=82=AF?= =?UTF-8?q?=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit READMEと管理者ガイドから、実際の画面を見て判断できるようにする。 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016uSBf6WpYKseYkhxDLKCLt --- README.md | 4 +++- README_ja.md | 4 +++- docs/ADMIN_GUIDE.md | 3 +++ docs/ADMIN_GUIDE_ja.md | 3 +++ 4 files changed, 12 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index c0d72f6..ae39d6b 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,8 @@ A resource reservation management system for laboratories. - **Resource Reservation Management**: Manage schedules for resources such as GPU servers and rooms - **Change Notifications**: Get notified when reservations are created, updated, or deleted - **Identity Linking**: Map user identities across different systems for enhanced notifications +- **Reservation Timeline**: Browse reservations as a resource-by-time timeline + ([live demo](https://kano-lab.github.io/lab-resource-manager/) — all data shown there is fictional) ### Default Implementations @@ -20,7 +22,7 @@ A resource reservation management system for laboratories. |-----------|----------------| | Resource Repository | Google Calendar | | Notifications | Slack | -| User Interface | Slack Bot | +| User Interface | Slack Bot, MCP server, read-only web timeline | | Access Control | Google Calendar ACL | ## Quick Start diff --git a/README_ja.md b/README_ja.md index 666dedf..dc17284 100644 --- a/README_ja.md +++ b/README_ja.md @@ -7,6 +7,8 @@ - **リソース予約管理**: GPUサーバーや部屋といった資源利用のスケジュールを管理 - **変更通知**: 予約の作成・更新・削除時に通知 - **Identity Linking**: 異なるシステム間でユーザーIDを紐付けて通知を強化 +- **予約タイムライン**: 予約をリソース×時間の表として閲覧 + ([デモ](https://kano-lab.github.io/lab-resource-manager/)。表示されるデータはすべて架空のものです) ### デフォルト実装 @@ -14,7 +16,7 @@ |---------------|------| | リソースリポジトリ | Google Calendar | | 通知 | Slack | -| ユーザーインターフェース | Slack Bot | +| ユーザーインターフェース | Slack Bot、MCPサーバー、閲覧専用のWebタイムライン | | アクセス制御 | Google Calendar ACL | ## クイックスタート diff --git a/docs/ADMIN_GUIDE.md b/docs/ADMIN_GUIDE.md index d89ca24..bcb4e06 100644 --- a/docs/ADMIN_GUIDE.md +++ b/docs/ADMIN_GUIDE.md @@ -394,6 +394,9 @@ is what a calendar UI is poor at, and this page fills that gap. As with the MCP HTTP listener starts as an extra task inside the existing `lab-resource-manager` process — no separate binary or systemd service is needed. +See the [live demo](https://kano-lab.github.io/lab-resource-manager/) for what it looks like +(the data there is fictional). + **This feature is experimental.** The environment variable names, the URL layout and the shape of the page may change in a minor release. The web framework underneath (Topcoat) is itself a 0.x release that expects breaking changes. Take that into account before building diff --git a/docs/ADMIN_GUIDE_ja.md b/docs/ADMIN_GUIDE_ja.md index 95389d6..a643a4b 100644 --- a/docs/ADMIN_GUIDE_ja.md +++ b/docs/ADMIN_GUIDE_ja.md @@ -381,6 +381,9 @@ TLS層とは独立してBearerトークンを要求すること)は開発環 MCPサーバーと同じく、既存の`lab-resource-manager`プロセス内に追加のタスクとして HTTPリスナーが起動します。新しいバイナリやsystemdサービスは不要です。 +画面の見た目は[デモ](https://kano-lab.github.io/lab-resource-manager/)で確かめられます +(架空のデータです)。 + **この機能は実験的です。** 環境変数の名前、URLの構成、画面の作りは、マイナーバージョンの 更新でも変わることがあります。土台にしているWebフレームワーク(Topcoat)自体が破壊的変更を 前提とした0.x系にあるためです。運用手順に組み込むときはこの点を織り込んでください。