diff --git a/src/console/src/api/factory.rs b/src/console/src/api/factory.rs index 3d1379194..489ac319c 100644 --- a/src/console/src/api/factory.rs +++ b/src/console/src/api/factory.rs @@ -1,3 +1,4 @@ +use crate::factory::canister::create_canister; use crate::factory::mission_control::create_mission_control as create_mission_control_console; use crate::factory::orbiter::create_orbiter as create_orbiter_console; use crate::factory::satellite::create_satellite as create_satellite_console; @@ -6,7 +7,7 @@ use ic_cdk_macros::update; use junobuild_shared::ic::api::caller; use junobuild_shared::ic::UnwrapOrTrap; use junobuild_shared::types::interface::{ - CreateMissionControlArgs, CreateOrbiterArgs, CreateSatelliteArgs, + CreateMissionControlArgs, CreateOrbiterArgs, CreateSatelliteArgs, CreateSegmentArgs, }; #[update] @@ -33,3 +34,19 @@ async fn create_orbiter(args: CreateOrbiterArgs) -> Principal { create_orbiter_console(caller, args).await.unwrap_or_trap() } + +#[update] +async fn create_segment(args: CreateSegmentArgs) -> Principal { + let caller = caller(); + + let result = match args { + CreateSegmentArgs::Satellite(args) => create_satellite_console(caller, args).await, + CreateSegmentArgs::MissionControl(args) => { + create_mission_control_console(caller, args).await + } + CreateSegmentArgs::Orbiter(args) => create_orbiter_console(caller, args).await, + CreateSegmentArgs::Canister(args) => create_canister(caller, args).await, + }; + + result.unwrap_or_trap() +} diff --git a/src/console/src/constants.rs b/src/console/src/constants.rs index 76b93a25b..733fe9db2 100644 --- a/src/console/src/constants.rs +++ b/src/console/src/constants.rs @@ -13,6 +13,7 @@ pub const SATELLITE_CREATION_FEE_CYCLES: CyclesTokens = CyclesTokens::from_e12s( pub const ORBITER_CREATION_FEE_CYCLES: CyclesTokens = CyclesTokens::from_e12s(3_000_000_000_000); pub const MISSION_CONTROL_CREATION_FEE_CYCLES: CyclesTokens = CyclesTokens::from_e12s(3_000_000_000_000); +pub const CANISTER_CREATION_FEE_CYCLES: CyclesTokens = CyclesTokens::from_e12s(3_000_000_000_000); // 1 ICP but also the default credit - i.e. a mission control starts with one credit. // A credit which can be used to start one satellite or one orbiter. diff --git a/src/console/src/factory/canister.rs b/src/console/src/factory/canister.rs new file mode 100644 index 000000000..517856c21 --- /dev/null +++ b/src/console/src/factory/canister.rs @@ -0,0 +1,83 @@ +use crate::accounts::get_existing_account; +use crate::constants::FREEZING_THRESHOLD_ONE_YEAR; +use crate::factory::orchestrator::create_segment_with_account; +use crate::factory::services::payment::{process_payment_cycles, refund_payment_cycles}; +use crate::factory::types::CanisterCreator; +use crate::factory::utils::controllers::update_mission_control_controllers; +use crate::fees::get_factory_fee; +use crate::rates::increment_canister_rate; +use crate::segments::add_segment as add_segment_store; +use crate::types::ledger::Fee; +use crate::types::state::{Segment, SegmentKey, StorableSegmentKind}; +use candid::{Nat, Principal}; +use junobuild_shared::constants::shared::CREATE_CANISTER_CYCLES; +use junobuild_shared::ic::api::id; +use junobuild_shared::mgmt::cmc::create_canister_with_cmc; +use junobuild_shared::mgmt::ic::create_canister_with_ic_mgmt; +use junobuild_shared::mgmt::types::cmc::SubnetId; +use junobuild_shared::mgmt::types::ic::CreateCanisterInitSettingsArg; +use junobuild_shared::types::interface::CreateCanisterArgs; +use junobuild_shared::types::state::{SegmentKind, UserId}; + +pub async fn create_canister( + caller: Principal, + args: CreateCanisterArgs, +) -> Result { + let account = get_existing_account(&caller)?; + + let name = args.name.clone(); + let creator: CanisterCreator = CanisterCreator::User((account.owner, None)); + + let fee = get_factory_fee(&SegmentKind::Canister)?.fee_cycles; + + let canister_id = create_segment_with_account( + create_raw_canister, + process_payment_cycles, + refund_payment_cycles, + &increment_canister_rate, + Fee::Cycles(fee), + &account, + creator, + args.into(), + ) + .await?; + + add_segment(&account.owner, &canister_id, &name); + + Ok(canister_id) +} + +async fn create_raw_canister( + creator: CanisterCreator, + subnet_id: Option, +) -> Result { + let CanisterCreator::User((user_id, _)) = creator else { + return Err("Mission Control cannot create a raw canister".to_string()); + }; + + // We temporarily use the Console as a controller to create the canister but + // remove it as soon as it is spin. + let temporary_init_controllers = Vec::from([id(), user_id]); + + let create_settings_arg = CreateCanisterInitSettingsArg { + controllers: temporary_init_controllers, + freezing_threshold: Nat::from(FREEZING_THRESHOLD_ONE_YEAR), + }; + + let mission_control_id = if let Some(subnet_id) = subnet_id { + create_canister_with_cmc(&create_settings_arg, CREATE_CANISTER_CYCLES, &subnet_id).await + } else { + create_canister_with_ic_mgmt(&create_settings_arg, CREATE_CANISTER_CYCLES).await + }?; + + update_mission_control_controllers(&mission_control_id, &user_id).await?; + + Ok(mission_control_id) +} + +fn add_segment(user: &UserId, canister_id: &Principal, name: &Option) { + let metadata = Segment::init_metadata(name); + let canister = Segment::new(canister_id, Some(metadata)); + let key = SegmentKey::from(user, canister_id, StorableSegmentKind::Canister); + add_segment_store(&key, &canister) +} diff --git a/src/console/src/factory/impls.rs b/src/console/src/factory/impls.rs index 073c3c830..2f7deacf4 100644 --- a/src/console/src/factory/impls.rs +++ b/src/console/src/factory/impls.rs @@ -2,7 +2,7 @@ use crate::factory::types::CanisterCreator; use crate::factory::types::CreateSegmentArgs; use candid::Principal; use junobuild_shared::types::interface::{ - CreateMissionControlArgs, CreateOrbiterArgs, CreateSatelliteArgs, + CreateCanisterArgs, CreateMissionControlArgs, CreateOrbiterArgs, CreateSatelliteArgs, }; use junobuild_shared::types::state::{AccessKeyId, UserId}; @@ -63,3 +63,14 @@ impl From for CreateSegmentArgs { } } } + +impl From for CreateSegmentArgs { + fn from(args: CreateCanisterArgs) -> Self { + Self { + // Unlike Satellite and Orbiter, or same as Mission Control, Canister can only be + // spin using credits or ICRC-2 transfer from. + block_index: None, + subnet_id: args.subnet_id, + } + } +} diff --git a/src/console/src/factory/mod.rs b/src/console/src/factory/mod.rs index 22157225a..182890340 100644 --- a/src/console/src/factory/mod.rs +++ b/src/console/src/factory/mod.rs @@ -1,3 +1,4 @@ +pub mod canister; mod impls; pub mod mission_control; pub mod orbiter; diff --git a/src/console/src/fees/init.rs b/src/console/src/fees/init.rs index f5c1d74a9..46bc82db5 100644 --- a/src/console/src/fees/init.rs +++ b/src/console/src/fees/init.rs @@ -1,6 +1,6 @@ use crate::constants::{ - MISSION_CONTROL_CREATION_FEE_CYCLES, ORBITER_CREATION_FEE_CYCLES, ORBITER_CREATION_FEE_ICP, - SATELLITE_CREATION_FEE_CYCLES, SATELLITE_CREATION_FEE_ICP, + CANISTER_CREATION_FEE_CYCLES, MISSION_CONTROL_CREATION_FEE_CYCLES, ORBITER_CREATION_FEE_CYCLES, + ORBITER_CREATION_FEE_ICP, SATELLITE_CREATION_FEE_CYCLES, SATELLITE_CREATION_FEE_ICP, }; use crate::types::state::{FactoryFee, FactoryFees}; use ic_cdk::api::time; @@ -35,5 +35,13 @@ pub fn init_factory_fees() -> FactoryFees { updated_at: now, }, ), + ( + SegmentKind::Canister, + FactoryFee { + fee_cycles: CANISTER_CREATION_FEE_CYCLES, + fee_icp: None, + updated_at: now, + }, + ), ]) } diff --git a/src/console/src/lib.rs b/src/console/src/lib.rs index d91c6186d..b6014c2b4 100644 --- a/src/console/src/lib.rs +++ b/src/console/src/lib.rs @@ -62,6 +62,7 @@ use junobuild_shared::types::domain::CustomDomains; use junobuild_shared::types::interface::CreateMissionControlArgs; use junobuild_shared::types::interface::CreateOrbiterArgs; use junobuild_shared::types::interface::CreateSatelliteArgs; +use junobuild_shared::types::interface::CreateSegmentArgs; use junobuild_shared::types::interface::{ AssertMissionControlCenterArgs, DeleteControllersArgs, GetCreateCanisterFeeArgs, SetControllersArgs, diff --git a/src/console/src/rates/init.rs b/src/console/src/rates/init.rs index f51bf0364..0ddd76f59 100644 --- a/src/console/src/rates/init.rs +++ b/src/console/src/rates/init.rs @@ -35,5 +35,12 @@ pub fn init_factory_rates() -> FactoryRates { tokens: tokens.clone(), }, ), + ( + SegmentKind::Canister, + FactoryRate { + config: DEFAULT_RATE_CONFIG, + tokens: tokens.clone(), + }, + ), ]) } diff --git a/src/console/src/rates/services.rs b/src/console/src/rates/services.rs index 18413b279..b97d824eb 100644 --- a/src/console/src/rates/services.rs +++ b/src/console/src/rates/services.rs @@ -12,3 +12,7 @@ pub fn increment_mission_controls_rate() -> Result<(), String> { pub fn increment_orbiters_rate() -> Result<(), String> { increment_rate(&SegmentKind::Orbiter) } + +pub fn increment_canister_rate() -> Result<(), String> { + increment_rate(&SegmentKind::Canister) +} diff --git a/src/console/src/segments/store.rs b/src/console/src/segments/store.rs index fc84b1c42..6c2dc5f39 100644 --- a/src/console/src/segments/store.rs +++ b/src/console/src/segments/store.rs @@ -98,7 +98,7 @@ fn filter_segments_range( let end_key = SegmentKey { user: *user, // Fallback to last enum - segment_kind: segment_kind.clone().unwrap_or(StorableSegmentKind::Orbiter), + segment_kind: segment_kind.clone().unwrap_or(StorableSegmentKind::Canister), segment_id: segment_id.unwrap_or(PRINCIPAL_MAX), }; diff --git a/src/console/src/types.rs b/src/console/src/types.rs index 48c4730ef..cd73be21a 100644 --- a/src/console/src/types.rs +++ b/src/console/src/types.rs @@ -152,6 +152,7 @@ pub mod state { // For historical reasons, MissionControl is not stored in the segments stable tree // but within the Account structure Orbiter, + Canister, } // On Apr. 4, 2026, someone exploited the free tier to spin up free canisters. diff --git a/src/declarations/console/console.factory.certified.did.js b/src/declarations/console/console.factory.certified.did.js index d142a2972..8517201a0 100644 --- a/src/declarations/console/console.factory.certified.did.js +++ b/src/declarations/console/console.factory.certified.did.js @@ -128,6 +128,16 @@ export const idlFactory = ({ IDL }) => { name: IDL.Opt(IDL.Text), user: IDL.Principal }); + const CreateCanisterArgs = IDL.Record({ + subnet_id: IDL.Opt(IDL.Principal), + name: IDL.Opt(IDL.Text) + }); + const CreateSegmentArgs = IDL.Variant({ + Orbiter: CreateOrbiterArgs, + MissionControl: CreateMissionControlArgs, + Canister: CreateCanisterArgs, + Satellite: CreateSatelliteArgs + }); const DeleteControllersArgs = IDL.Record({ controllers: IDL.Vec(IDL.Principal) }); @@ -232,6 +242,7 @@ export const idlFactory = ({ IDL }) => { const SegmentKind = IDL.Variant({ Orbiter: IDL.Null, MissionControl: IDL.Null, + Canister: IDL.Null, Satellite: IDL.Null }); const CyclesTokens = IDL.Record({ e12s: IDL.Nat64 }); @@ -440,6 +451,7 @@ export const idlFactory = ({ IDL }) => { }); const StorableSegmentKind = IDL.Variant({ Orbiter: IDL.Null, + Canister: IDL.Null, Satellite: IDL.Null }); const ListSegmentsArgs = IDL.Record({ @@ -528,6 +540,7 @@ export const idlFactory = ({ IDL }) => { create_mission_control: IDL.Func([CreateMissionControlArgs], [IDL.Principal], []), create_orbiter: IDL.Func([CreateOrbiterArgs], [IDL.Principal], []), create_satellite: IDL.Func([CreateSatelliteArgs], [IDL.Principal], []), + create_segment: IDL.Func([CreateSegmentArgs], [IDL.Principal], []), del_controllers: IDL.Func([DeleteControllersArgs], [], []), del_custom_domain: IDL.Func([IDL.Text], [], []), delete_proposal_assets: IDL.Func([DeleteProposalAssets], [], []), diff --git a/src/declarations/console/console.factory.did.js b/src/declarations/console/console.factory.did.js index 1493a47c2..9334b5d23 100644 --- a/src/declarations/console/console.factory.did.js +++ b/src/declarations/console/console.factory.did.js @@ -128,6 +128,16 @@ export const idlFactory = ({ IDL }) => { name: IDL.Opt(IDL.Text), user: IDL.Principal }); + const CreateCanisterArgs = IDL.Record({ + subnet_id: IDL.Opt(IDL.Principal), + name: IDL.Opt(IDL.Text) + }); + const CreateSegmentArgs = IDL.Variant({ + Orbiter: CreateOrbiterArgs, + MissionControl: CreateMissionControlArgs, + Canister: CreateCanisterArgs, + Satellite: CreateSatelliteArgs + }); const DeleteControllersArgs = IDL.Record({ controllers: IDL.Vec(IDL.Principal) }); @@ -232,6 +242,7 @@ export const idlFactory = ({ IDL }) => { const SegmentKind = IDL.Variant({ Orbiter: IDL.Null, MissionControl: IDL.Null, + Canister: IDL.Null, Satellite: IDL.Null }); const CyclesTokens = IDL.Record({ e12s: IDL.Nat64 }); @@ -440,6 +451,7 @@ export const idlFactory = ({ IDL }) => { }); const StorableSegmentKind = IDL.Variant({ Orbiter: IDL.Null, + Canister: IDL.Null, Satellite: IDL.Null }); const ListSegmentsArgs = IDL.Record({ @@ -528,6 +540,7 @@ export const idlFactory = ({ IDL }) => { create_mission_control: IDL.Func([CreateMissionControlArgs], [IDL.Principal], []), create_orbiter: IDL.Func([CreateOrbiterArgs], [IDL.Principal], []), create_satellite: IDL.Func([CreateSatelliteArgs], [IDL.Principal], []), + create_segment: IDL.Func([CreateSegmentArgs], [IDL.Principal], []), del_controllers: IDL.Func([DeleteControllersArgs], [], []), del_custom_domain: IDL.Func([IDL.Text], [], []), delete_proposal_assets: IDL.Func([DeleteProposalAssets], [], []), diff --git a/src/declarations/console/console.factory.did.mjs b/src/declarations/console/console.factory.did.mjs index 1493a47c2..9334b5d23 100644 --- a/src/declarations/console/console.factory.did.mjs +++ b/src/declarations/console/console.factory.did.mjs @@ -128,6 +128,16 @@ export const idlFactory = ({ IDL }) => { name: IDL.Opt(IDL.Text), user: IDL.Principal }); + const CreateCanisterArgs = IDL.Record({ + subnet_id: IDL.Opt(IDL.Principal), + name: IDL.Opt(IDL.Text) + }); + const CreateSegmentArgs = IDL.Variant({ + Orbiter: CreateOrbiterArgs, + MissionControl: CreateMissionControlArgs, + Canister: CreateCanisterArgs, + Satellite: CreateSatelliteArgs + }); const DeleteControllersArgs = IDL.Record({ controllers: IDL.Vec(IDL.Principal) }); @@ -232,6 +242,7 @@ export const idlFactory = ({ IDL }) => { const SegmentKind = IDL.Variant({ Orbiter: IDL.Null, MissionControl: IDL.Null, + Canister: IDL.Null, Satellite: IDL.Null }); const CyclesTokens = IDL.Record({ e12s: IDL.Nat64 }); @@ -440,6 +451,7 @@ export const idlFactory = ({ IDL }) => { }); const StorableSegmentKind = IDL.Variant({ Orbiter: IDL.Null, + Canister: IDL.Null, Satellite: IDL.Null }); const ListSegmentsArgs = IDL.Record({ @@ -528,6 +540,7 @@ export const idlFactory = ({ IDL }) => { create_mission_control: IDL.Func([CreateMissionControlArgs], [IDL.Principal], []), create_orbiter: IDL.Func([CreateOrbiterArgs], [IDL.Principal], []), create_satellite: IDL.Func([CreateSatelliteArgs], [IDL.Principal], []), + create_segment: IDL.Func([CreateSegmentArgs], [IDL.Principal], []), del_controllers: IDL.Func([DeleteControllersArgs], [], []), del_custom_domain: IDL.Func([IDL.Text], [], []), delete_proposal_assets: IDL.Func([DeleteProposalAssets], [], []), diff --git a/src/declarations/observatory/observatory.factory.certified.did.js b/src/declarations/observatory/observatory.factory.certified.did.js index d7fda8452..ff3145d18 100644 --- a/src/declarations/observatory/observatory.factory.certified.did.js +++ b/src/declarations/observatory/observatory.factory.certified.did.js @@ -106,6 +106,7 @@ export const idlFactory = ({ IDL }) => { const SegmentKind = IDL.Variant({ Orbiter: IDL.Null, MissionControl: IDL.Null, + Canister: IDL.Null, Satellite: IDL.Null }); const Segment = IDL.Record({ diff --git a/src/declarations/observatory/observatory.factory.did.js b/src/declarations/observatory/observatory.factory.did.js index 39c8f2f85..21703691a 100644 --- a/src/declarations/observatory/observatory.factory.did.js +++ b/src/declarations/observatory/observatory.factory.did.js @@ -106,6 +106,7 @@ export const idlFactory = ({ IDL }) => { const SegmentKind = IDL.Variant({ Orbiter: IDL.Null, MissionControl: IDL.Null, + Canister: IDL.Null, Satellite: IDL.Null }); const Segment = IDL.Record({ diff --git a/src/declarations/observatory/observatory.factory.did.mjs b/src/declarations/observatory/observatory.factory.did.mjs index 39c8f2f85..21703691a 100644 --- a/src/declarations/observatory/observatory.factory.did.mjs +++ b/src/declarations/observatory/observatory.factory.did.mjs @@ -106,6 +106,7 @@ export const idlFactory = ({ IDL }) => { const SegmentKind = IDL.Variant({ Orbiter: IDL.Null, MissionControl: IDL.Null, + Canister: IDL.Null, Satellite: IDL.Null }); const Segment = IDL.Record({ diff --git a/src/frontend/src/lib/api/console.api.ts b/src/frontend/src/lib/api/console.api.ts index 122ae243a..b9c28b994 100644 --- a/src/frontend/src/lib/api/console.api.ts +++ b/src/frontend/src/lib/api/console.api.ts @@ -55,6 +55,12 @@ export const getMissionControlFee = async ({ }): Promise => await getFee({ identity, segmentKind: { MissionControl: null } }); +export const getCanisterFee = async ({ + identity +}: { + identity: OptionIdentity; +}): Promise => await getFee({ identity, segmentKind: { Canister: null } }); + const getFee = async ({ identity, segmentKind diff --git a/src/frontend/src/lib/components/canister-segment/CanisterOverview.svelte b/src/frontend/src/lib/components/canister-segment/CanisterOverview.svelte new file mode 100644 index 000000000..0cfbb8c0d --- /dev/null +++ b/src/frontend/src/lib/components/canister-segment/CanisterOverview.svelte @@ -0,0 +1,93 @@ + + + + +
+ {$i18n.satellites.overview} + +
+
+ + + + + +
+ +
+ + {#snippet label()} + {$i18n.canister.id} + {/snippet} + + + + +
+
+
+ + + +
+ {$i18n.monitoring.runtime} + +
+ +
+
+ + + + diff --git a/src/frontend/src/lib/components/canister-segment/CanisterSettings.svelte b/src/frontend/src/lib/components/canister-segment/CanisterSettings.svelte new file mode 100644 index 000000000..b9ed9ac8f --- /dev/null +++ b/src/frontend/src/lib/components/canister-segment/CanisterSettings.svelte @@ -0,0 +1,27 @@ + + + + + diff --git a/src/frontend/src/lib/components/guards/CanisterGuard.svelte b/src/frontend/src/lib/components/guards/CanisterGuard.svelte new file mode 100644 index 000000000..1ad5f6033 --- /dev/null +++ b/src/frontend/src/lib/components/guards/CanisterGuard.svelte @@ -0,0 +1,25 @@ + + +{#if $consoleCanisters === undefined} + {$i18n.canister.loading_canisters} +{:else if $canisterStore === null} +
+ +
+{:else} + {@render children()} +{/if} diff --git a/src/frontend/src/lib/components/icons/IconCanister.svelte b/src/frontend/src/lib/components/icons/IconCanister.svelte new file mode 100644 index 000000000..fd4d18392 --- /dev/null +++ b/src/frontend/src/lib/components/icons/IconCanister.svelte @@ -0,0 +1,14 @@ + + + + diff --git a/src/frontend/src/lib/components/modals/Modals.svelte b/src/frontend/src/lib/components/modals/Modals.svelte index 160eb70d3..11aeed4ea 100644 --- a/src/frontend/src/lib/components/modals/Modals.svelte +++ b/src/frontend/src/lib/components/modals/Modals.svelte @@ -13,6 +13,7 @@ import MissionControlTransferCyclesModal from '$lib/components/modals/cycles/transfer/MissionControlTransferCyclesModal.svelte'; import OrbiterTransferCyclesModal from '$lib/components/modals/cycles/transfer/OrbiterTransferCyclesModal.svelte'; import SatelliteTransferCyclesModal from '$lib/components/modals/cycles/transfer/SatelliteTransferCyclesModal.svelte'; + import CanisterCreateModal from '$lib/components/modals/factory/create/CanisterCreateModal.svelte'; import MissionControlCreateModal from '$lib/components/modals/factory/create/MissionControlCreateModal.svelte'; import OrbiterCreateModal from '$lib/components/modals/factory/create/OrbiterCreateModal.svelte'; import SatelliteCreateModal from '$lib/components/modals/factory/create/SatelliteCreateModal.svelte'; @@ -55,6 +56,10 @@ {/if} +{#if modal?.type === 'create_canister' && nonNullish(modal.detail)} + +{/if} + {#if modal?.type === 'topup_satellite' && nonNullish(modal.detail)} {/if} diff --git a/src/frontend/src/lib/components/modals/factory/create/CanisterCreateModal.svelte b/src/frontend/src/lib/components/modals/factory/create/CanisterCreateModal.svelte new file mode 100644 index 000000000..65eef89db --- /dev/null +++ b/src/frontend/src/lib/components/modals/factory/create/CanisterCreateModal.svelte @@ -0,0 +1,187 @@ + + + + {#if step === 'ready'} + + +
+

{$i18n.canister.ready}

+ +
+ {:else if step === 'in_progress'} + + {:else} +

{$i18n.canister.start}

+ +

+ {$i18n.canister.description} +

+ + +
+ + {#snippet label()} + {$i18n.canister.canister_name} + {/snippet} + + + + + + + +
+ {/if} +
+ + diff --git a/src/frontend/src/lib/components/modules/factory/create/FactoryProgressCreate.svelte b/src/frontend/src/lib/components/modules/factory/create/FactoryProgressCreate.svelte index 36168e0d2..6cb452b04 100644 --- a/src/frontend/src/lib/components/modules/factory/create/FactoryProgressCreate.svelte +++ b/src/frontend/src/lib/components/modules/factory/create/FactoryProgressCreate.svelte @@ -13,7 +13,7 @@ interface Props { progress: FactoryCreateProgress | undefined; - segment: 'satellite' | 'mission_control' | 'orbiter'; + segment: 'satellite' | 'mission_control' | 'orbiter' | 'canister'; withApprove: boolean; withMonitoring?: boolean; withAttach?: boolean; @@ -61,7 +61,9 @@ ? $i18n.mission_control.initializing : segment === 'orbiter' ? $i18n.analytics.initializing - : $i18n.satellites.initializing + : segment === 'canister' + ? $i18n.canister.initializing + : $i18n.satellites.initializing }, ...(withAttach === true && { attaching: { @@ -72,7 +74,9 @@ ? (attachProgressText ?? $i18n.mission_control.attaching) : segment === 'orbiter' ? $i18n.analytics.attaching - : $i18n.satellites.attaching + : segment === 'canister' + ? $i18n.canister.attaching + : $i18n.satellites.attaching } }), ...(withMonitoring === true && { diff --git a/src/frontend/src/lib/components/modules/launchpad/Launchpad.svelte b/src/frontend/src/lib/components/modules/launchpad/Launchpad.svelte index 5a456a464..3ce70d109 100644 --- a/src/frontend/src/lib/components/modules/launchpad/Launchpad.svelte +++ b/src/frontend/src/lib/components/modules/launchpad/Launchpad.svelte @@ -20,6 +20,8 @@ loading = true; })(); }); + + // TODO: handle as one canister but no Satellites {#if loading || ($satellites?.length ?? 0n) === 0} diff --git a/src/frontend/src/lib/components/modules/launchpad/LaunchpadNewActions.svelte b/src/frontend/src/lib/components/modules/launchpad/LaunchpadNewActions.svelte index cdf4755af..f473f8433 100644 --- a/src/frontend/src/lib/components/modules/launchpad/LaunchpadNewActions.svelte +++ b/src/frontend/src/lib/components/modules/launchpad/LaunchpadNewActions.svelte @@ -1,6 +1,7 @@ diff --git a/src/frontend/src/lib/derived/app/page.derived.svelte.ts b/src/frontend/src/lib/derived/app/page.derived.svelte.ts index 0456a9460..cc518246f 100644 --- a/src/frontend/src/lib/derived/app/page.derived.svelte.ts +++ b/src/frontend/src/lib/derived/app/page.derived.svelte.ts @@ -1,17 +1,33 @@ import { page } from '$app/state'; +import type { CanisterIdText } from '$lib/types/canister'; import type { SatelliteIdText } from '$lib/types/satellite'; +import { notEmptyString } from '@dfinity/utils'; import { type Readable, writable } from 'svelte/store'; -type PageSatelliteIdStoreData = SatelliteIdText | undefined; +type PageIdStoreData = + | { + satelliteId: SatelliteIdText; + } + | { canisterId: CanisterIdText } + | undefined; -export type PageSatelliteIdStore = Readable; +export type PageIdStore = Readable; -const initPageSatelliteIdStore = (): PageSatelliteIdStore => { - const { subscribe, set } = writable(undefined); +const initPageIdStore = (): PageIdStore => { + const { subscribe, set } = writable(undefined); $effect.root(() => { $effect(() => { - set(page.data?.satellite); + const satelliteId = page.data?.satellite; + const canisterId = page.data?.canister; + + set( + notEmptyString(satelliteId) + ? { satelliteId } + : notEmptyString(canisterId) + ? { canisterId } + : undefined + ); }); }); @@ -20,4 +36,4 @@ const initPageSatelliteIdStore = (): PageSatelliteIdStore => { }; }; -export const pageSatelliteId = initPageSatelliteIdStore(); +export const pageId = initPageIdStore(); diff --git a/src/frontend/src/lib/derived/canister.derived.ts b/src/frontend/src/lib/derived/canister.derived.ts new file mode 100644 index 000000000..88eeba12b --- /dev/null +++ b/src/frontend/src/lib/derived/canister.derived.ts @@ -0,0 +1,30 @@ +import { pageId } from '$lib/derived/app/page.derived.svelte'; +import { consoleCanisters } from '$lib/derived/console/segments.derived'; +import type { SegmentCanister } from '$lib/types/segment'; +import type { Option } from '$lib/types/utils'; +import { isNullish } from '@dfinity/utils'; +import { derived, type Readable } from 'svelte/store'; + +export const canisterStore: Readable> = derived( + [consoleCanisters, pageId], + ([$consoleCanisters, $pageId]) => { + if (isNullish($pageId)) { + return null; + } + + if (!('canisterId' in $pageId)) { + return null; + } + + // Canisters are not loaded yet + if ($consoleCanisters === undefined) { + return undefined; + } + + const canister = ($consoleCanisters ?? []).find( + ({ canisterId }) => canisterId.toText() === $pageId.canisterId + ); + + return canister === undefined ? null : canister; + } +); diff --git a/src/frontend/src/lib/derived/console/canisters.derived.ts b/src/frontend/src/lib/derived/console/canisters.derived.ts new file mode 100644 index 000000000..377de5f8c --- /dev/null +++ b/src/frontend/src/lib/derived/console/canisters.derived.ts @@ -0,0 +1,20 @@ +import { consoleCanisters } from '$lib/derived/console/segments.derived'; +import type { Satellite } from '$lib/types/satellite'; +import type { SegmentCanisterUi } from '$lib/types/segment'; +import { satelliteMetadata, satelliteName } from '$lib/utils/satellite.utils'; +import { derived } from 'svelte/store'; + +export const sortedCanisters = derived([consoleCanisters], ([$consoleCanisters]) => + // TODO: make functions generic + ($consoleCanisters ?? []).sort((a, b) => + satelliteName(a as unknown as Satellite).localeCompare(satelliteName(b as unknown as Satellite)) + ) +); + +export const sortedCanisterUis = derived([sortedCanisters], ([$sortedCanisters]) => + $sortedCanisters.map((segment) => ({ + ...segment, + // TODO: make function generic + metadata: satelliteMetadata(segment as unknown as Satellite) + })) +); diff --git a/src/frontend/src/lib/derived/console/segments.derived.ts b/src/frontend/src/lib/derived/console/segments.derived.ts index 65ec4c402..26542ba41 100644 --- a/src/frontend/src/lib/derived/console/segments.derived.ts +++ b/src/frontend/src/lib/derived/console/segments.derived.ts @@ -2,6 +2,7 @@ import { segmentsUncertifiedStore } from '$lib/stores/console/segments.store'; import type { Orbiter } from '$lib/types/orbiter'; import type { Satellite } from '$lib/types/satellite'; import { sortSatellites } from '$lib/utils/satellite.utils'; +import type { SegmentCanister } from '$lib/types/segment'; import { derived } from 'svelte/store'; export const segments = derived( @@ -37,3 +38,13 @@ export const consoleOrbiter = derived( [consoleOrbiters], ([$consoleOrbiters]) => $consoleOrbiters?.[0] ); + +export const consoleCanisters = derived([segments], ([$segments]) => + $segments + ?.filter(([{ segment_kind }]) => 'Canister' in segment_kind) + .map(([_, { segment_id, ...rest }]) => ({ + canisterId: segment_id, + settings: [], + ...rest + })) +); \ No newline at end of file diff --git a/src/frontend/src/lib/i18n/en.json b/src/frontend/src/lib/i18n/en.json index 51e0cbc81..fa4df07dc 100644 --- a/src/frontend/src/lib/i18n/en.json +++ b/src/frontend/src/lib/i18n/en.json @@ -123,6 +123,22 @@ "redirecting": "Redirecting, hold on...", "lets_go": "Let's go!" }, + "canister": { + "title": "Canister", + "launch": "Spin up a Canister", + "initializing": "Spinning up your Canister...", + "attaching": "Sharing Canister with Mission Control...", + "start": "Spin up a canister", + "description": "A canister is a blank container waiting for your code. Use it to implement your ideas from scratch.", + "create_canister_price": "Starting a new Canister requires {0}. Your current balance is {1}.", + "canister_name": "Canister name", + "enter_name": "Enter a name for your Canister", + "create": "Create Canister", + "ready": "Your Canister is ready!", + "overview": "Overview", + "loading_canisters": "Loading your Canisters", + "id": "Canister ID" + }, "canisters": { "top_up": "Top-up", "topping_up": "Topping-up", @@ -623,6 +639,9 @@ "satellite_metadata_update": "Unexpected error(s) while trying to set the metadata of your Satellite.", "satellite_missing_name": "A name must be provided.", "satellites_not_loaded": "The Satellites data are not yet loaded.", + "create_canister_name_missing": "A name for the Canister must be provided.", + "create_canister_unexpected_error": "Unexpected error(s) while creating the Canister.", + "canister_no_found": "Nothing here. Return to your launchpad to find your Canisters.", "canister_stop": "Unexpected error(s) while trying to stop the module.", "canister_start": "Unexpected error(s) while trying to start the module.", "canister_delete": "Unexpected error(s) while trying to delete the module.", diff --git a/src/frontend/src/lib/schemas/canister.schema.ts b/src/frontend/src/lib/schemas/canister.schema.ts index 8e86caa64..8e886d1e9 100644 --- a/src/frontend/src/lib/schemas/canister.schema.ts +++ b/src/frontend/src/lib/schemas/canister.schema.ts @@ -1,3 +1,4 @@ -import { PrincipalTextSchema } from '@junobuild/schema'; +import { PrincipalTextSchema, PrincipalSchema } from '@junobuild/schema'; export const CanisterIdTextSchema = PrincipalTextSchema; +export const CanisterIdSchema = PrincipalSchema; diff --git a/src/frontend/src/lib/services/factory/factory.create.services.ts b/src/frontend/src/lib/services/factory/factory.create.services.ts index 67e382e56..0ac681788 100644 --- a/src/frontend/src/lib/services/factory/factory.create.services.ts +++ b/src/frontend/src/lib/services/factory/factory.create.services.ts @@ -38,6 +38,7 @@ import { busy } from '$lib/stores/app/busy.store'; import { i18n } from '$lib/stores/app/i18n.store'; import { toasts } from '$lib/stores/app/toasts.store'; import type { + CreateCanisterConfig, CreateSatelliteConfig, CreateWithConfig, CreateWithConfigAndName @@ -106,6 +107,20 @@ export const initMissionControlWizard = ({ modalType: 'create_mission_control' }); +export const initCanisterWizard = ({ + missionControlId, + identity +}: { + missionControlId: Option; + identity: OptionIdentity; +}): Promise => + initCreateWizard({ + missionControlId, + identity, + feeFn: getCreateCanisterFeeBalance, + modalType: 'create_canister' + }); + const initCreateWizard = async ({ missionControlId, identity, @@ -115,7 +130,7 @@ const initCreateWizard = async ({ missionControlId: Nullish; identity: NullishIdentity; feeFn: GetFeeBalanceFn; - modalType: 'create_satellite' | 'create_orbiter' | 'create_mission_control'; + modalType: 'create_satellite' | 'create_orbiter' | 'create_mission_control' | 'create_canister'; }) => { if (missionControlId === undefined) { toasts.warn(get(i18n).errors.mission_control_not_loaded); @@ -216,6 +231,9 @@ const getCreateOrbiterFeeBalance: GetFeeBalanceFn = async (params): Promise => await getCreateFeeBalance({ ...params, getFee: getMissionControlFee }); +const getCreateCanisterFeeBalance: GetFeeBalanceFn = async (params): Promise => + await getCreateFeeBalance({ ...params, getFee: getCanisterFee }); + const getCreateFeeBalance = async ({ identity, getFee @@ -619,6 +637,130 @@ export const createMissionControlWizard = async ({ }); }; +export const createCanisterWizard = async ({ + missionControlId, + onProgress, + subnetId, + canisterName, + monitoringStrategy, + ...rest +}: CreateWizardParams & { + canisterName: string | undefined; +}): Promise => { + if (isEmptyString(canisterName)) { + toasts.error({ + text: get(i18n).errors.create_canister_name_missing + }); + return { success: 'error' }; + } + + const createFn: CreateFn = async ({ identity, selectedWallet: { type: walletType } }) => { + if (walletType === 'mission_control') { + // TODO: + throw new Error('Mission Control wallet not supported'); + } + + return await createWithConsoleFn({ identity }); + }; + + const createConfig: CreateCanisterConfig = { + name: canisterName, + ...(nonNullish(subnetId) && { subnetId: Principal.fromText(subnetId) }) + }; + + const createWithConsoleFn = async ({ identity }: { identity: Identity }): Promise => + await createCanisterWithConsoleAndConfig({ + identity, + config: createConfig + }); + + const buildAttachFn = (): AttachFn | undefined => { + if (isNullish(missionControlId)) { + return undefined; + } + + const attachFn: AttachFn = async ({ + identity, + canisterId, + selectedWallet: { type: walletType } + }) => { + // Mission Control already knowns the newly created module + if (walletType === 'mission_control') { + // TODO: + // 1. Handle error + // 2. Do not show Mission Control wallet in the UI if defined - in the dropdown + throw new Error('Mission Control wallet not supported'); + } + + // Attach the Satellite to the existing Mission Control. + // The controller for the Mission Control to the Satellite has been set by the Console backend. + // await attachSatelliteToMissionControl({ + // missionControlId, + // satelliteId: canisterId, + // identity, + // satelliteName + // }); + + // TODO: + throw new Error('Attach to Mission Control not yet supported'); + }; + + return attachFn; + }; + + const attachFn = buildAttachFn(); + + const buildMonitoringFn = (): MonitoringFn | undefined => { + if (isNullish(monitoringStrategy)) { + return undefined; + } + + return async ({ + identity, + canisterId + }: { + identity: Identity; + canisterId: Principal; + }): Promise => { + assertNonNullish(missionControlId); + + // await updateAndStartMonitoring({ + // identity, + // missionControlId, + // config: { + // cycles_config: toNullable({ + // mission_control_strategy: toNullable(), + // satellites_strategy: toNullable({ + // strategy: monitoringStrategy, + // ids: [canisterId] + // }), + // orbiters_strategy: toNullable() + // }) + // } + // }); + + // TODO: + throw new Error('Start monitoring not yet supported'); + }; + }; + + const monitoringFn = buildMonitoringFn(); + + const reloadFn: ReloadFn = async () => { + await loadSegments({ missionControlId, reload: true, reloadOrbiters: false }); + }; + + return await createWizard({ + ...rest, + onProgress, + createFn, + reloadFn, + attachFn, + monitoringFn, + errorLabel: 'create_canister_unexpected_error' + }); +}; + type CreateFn = (params: { identity: Identity; selectedWallet: SelectedWallet; diff --git a/src/frontend/src/lib/types/canister.ts b/src/frontend/src/lib/types/canister.ts index af15c2543..bad6c9027 100644 --- a/src/frontend/src/lib/types/canister.ts +++ b/src/frontend/src/lib/types/canister.ts @@ -1,4 +1,4 @@ -import type { CanisterIdTextSchema } from '$lib/schemas/canister.schema'; +import type { CanisterIdSchema, CanisterIdTextSchema } from '$lib/schemas/canister.schema'; import type { ChartsData, TimeOfDayChartData } from '$lib/types/chart'; import type { MonitoringHistory, MonitoringMetadata } from '$lib/types/monitoring'; import type { CertifiedData } from '$lib/types/store'; @@ -37,7 +37,7 @@ export interface CanisterMemoryMetrics { customSectionsSize: bigint; } -export type Segment = 'satellite' | 'mission_control' | 'orbiter'; +export type Segment = 'satellite' | 'mission_control' | 'orbiter' | 'canister'; export interface CanisterSegment { canisterId: string; @@ -83,6 +83,7 @@ export interface CanisterMonitoringData { charts: CanisterMonitoringCharts; } +export type CanisterId = z.infer; export type CanisterIdText = z.infer; export interface Canister { diff --git a/src/frontend/src/lib/types/factory.ts b/src/frontend/src/lib/types/factory.ts index 83ff56fcd..2d8c98565 100644 --- a/src/frontend/src/lib/types/factory.ts +++ b/src/frontend/src/lib/types/factory.ts @@ -12,3 +12,6 @@ export interface CreateSatelliteConfig extends CreateWithConfig { name: string; kind: 'website' | 'application'; } +export interface CreateCanisterConfig extends CreateWithConfig { + name: string; +} diff --git a/src/frontend/src/lib/types/i18n.d.ts b/src/frontend/src/lib/types/i18n.d.ts index cc08ffaa0..fad6b353c 100644 --- a/src/frontend/src/lib/types/i18n.d.ts +++ b/src/frontend/src/lib/types/i18n.d.ts @@ -127,6 +127,23 @@ interface I18nCore { lets_go: string; } +interface I18nCanister { + title: string; + launch: string; + initializing: string; + attaching: string; + start: string; + description: string; + create_canister_price: string; + canister_name: string; + enter_name: string; + create: string; + ready: string; + overview: string; + loading_canisters: string; + id: string; +} + interface I18nCanisters { top_up: string; topping_up: string; @@ -641,6 +658,9 @@ interface I18nErrors { satellite_metadata_update: string; satellite_missing_name: string; satellites_not_loaded: string; + create_canister_name_missing: string; + create_canister_unexpected_error: string; + canister_no_found: string; canister_stop: string; canister_start: string; canister_delete: string; @@ -1206,6 +1226,7 @@ interface I18nAutomation { interface I18n { lang: Languages; core: I18nCore; + canister: I18nCanister; canisters: I18nCanisters; sign_in: I18nSign_in; sign_in_openid: I18nSign_in_openid; diff --git a/src/frontend/src/lib/types/modal.ts b/src/frontend/src/lib/types/modal.ts index 1a213ac27..426aef935 100644 --- a/src/frontend/src/lib/types/modal.ts +++ b/src/frontend/src/lib/types/modal.ts @@ -164,6 +164,7 @@ export interface JunoModal { | 'create_satellite' | 'create_orbiter' | 'create_mission_control' + | 'create_canister' | 'delete_satellite' | 'delete_orbiter' | 'transfer_cycles_satellite' diff --git a/src/frontend/src/lib/types/segment.ts b/src/frontend/src/lib/types/segment.ts index 7f03ded7f..a0add249d 100644 --- a/src/frontend/src/lib/types/segment.ts +++ b/src/frontend/src/lib/types/segment.ts @@ -1,8 +1,20 @@ -import type { MissionControlDid } from '$declarations'; -import type { CanisterSyncData } from '$lib/types/canister'; -import type { Satellite } from '$lib/types/satellite'; +import type { ConsoleDid, MissionControlDid } from '$declarations'; +import type { CanisterId, CanisterSyncData } from '$lib/types/canister'; +import type { Satellite, SatelliteUiMetadata } from '$lib/types/satellite'; export interface SegmentWithSyncData { segment: T; canister: CanisterSyncData; } + +// TODO: to adapt +// 1. Naming is meh +// 2. Settings when Mission Control supports canister +export type SegmentCanister = Omit & { + canisterId: CanisterId; +} & Pick; + +// TODO: rename and move SatelliteUiMetadata +export type SegmentCanisterUi = Omit & { + metadata: SatelliteUiMetadata; +}; diff --git a/src/frontend/src/routes/(split)/canister/+layout.svelte b/src/frontend/src/routes/(split)/canister/+layout.svelte new file mode 100644 index 000000000..8a17834af --- /dev/null +++ b/src/frontend/src/routes/(split)/canister/+layout.svelte @@ -0,0 +1,33 @@ + + +{@render children()} diff --git a/src/frontend/src/routes/(split)/canister/+page.svelte b/src/frontend/src/routes/(split)/canister/+page.svelte new file mode 100644 index 000000000..393ebd87f --- /dev/null +++ b/src/frontend/src/routes/(split)/canister/+page.svelte @@ -0,0 +1,61 @@ + + + + + + + {#snippet info()} + {#if nonNullish($satelliteStore)} + + {/if} + {/snippet} + + {#if nonNullish($canisterStore)} + {#if $store.tabId === $store.tabs[0].id} + + + + {/if} + {/if} + + + + diff --git a/src/frontend/src/routes/(split)/canister/+page.ts b/src/frontend/src/routes/(split)/canister/+page.ts new file mode 100644 index 000000000..a4b40d898 --- /dev/null +++ b/src/frontend/src/routes/(split)/canister/+page.ts @@ -0,0 +1,5 @@ +import { loadRouteCanister, type RouteCanister } from '$lib/utils/nav.utils'; +import type { LoadEvent } from '@sveltejs/kit'; +import type { PageLoad } from './$types'; + +export const load: PageLoad = ($event: LoadEvent): RouteCanister => loadRouteCanister($event); diff --git a/src/frontend/src/routes/(standalone)/cli/+page.svelte b/src/frontend/src/routes/(standalone)/cli/+page.svelte index a0dee00c3..0081e01f1 100644 --- a/src/frontend/src/routes/(standalone)/cli/+page.svelte +++ b/src/frontend/src/routes/(standalone)/cli/+page.svelte @@ -10,6 +10,7 @@ import Message from '$lib/components/ui/Message.svelte'; import { authSignedIn } from '$lib/derived/auth.derived'; import { missionControlId } from '$lib/derived/console/account.mission-control.derived'; + import { consoleCanisters } from '$lib/derived/console/segments.derived'; import { sortedSatellites } from '$lib/derived/satellites.derived'; import { onIntersection } from '$lib/directives/intersection.directives'; import { i18n } from '$lib/stores/app/i18n.store'; @@ -34,7 +35,7 @@ {#if nonNullish(redirect_uri) && nonNullish(principal) && notEmptyString(redirect_uri) && notEmptyString(principal)} {#if $authSignedIn} - +
diff --git a/src/libs/shared/src/constants/shared.rs b/src/libs/shared/src/constants/shared.rs index 90eb869a0..854577083 100644 --- a/src/libs/shared/src/constants/shared.rs +++ b/src/libs/shared/src/constants/shared.rs @@ -19,6 +19,7 @@ pub const IC_CREATE_CANISTER_CYCLES: u128 = 500_000_000_000u128; pub const CREATE_SATELLITE_CYCLES: u128 = 1_000_000_000_000; pub const CREATE_MISSION_CONTROL_CYCLES: u128 = 1_000_000_000_000; pub const CREATE_ORBITER_CYCLES: u128 = 1_000_000_000_000; +pub const CREATE_CANISTER_CYCLES: u128 = 1_000_000_000_000; // Reverse (CREA -> AERC) -> ASCII -> HEX -> LittleEndian // NNS canister create: CREA 0x41455243 diff --git a/src/libs/shared/src/impls.rs b/src/libs/shared/src/impls.rs index b944d9fe2..e6d3568c3 100644 --- a/src/libs/shared/src/impls.rs +++ b/src/libs/shared/src/impls.rs @@ -20,6 +20,7 @@ impl Display for SegmentKind { SegmentKind::Satellite => write!(f, "Satellite"), SegmentKind::MissionControl => write!(f, "Mission Control"), SegmentKind::Orbiter => write!(f, "Orbiter"), + SegmentKind::Canister => write!(f, "Canister"), } } } diff --git a/src/libs/shared/src/types.rs b/src/libs/shared/src/types.rs index 3b29bbcdc..f0826e6c7 100644 --- a/src/libs/shared/src/types.rs +++ b/src/libs/shared/src/types.rs @@ -63,6 +63,7 @@ pub mod state { Satellite, MissionControl, Orbiter, + Canister, } #[derive(CandidType, Serialize, Deserialize, Clone)] @@ -117,6 +118,14 @@ pub mod interface { use ic_ledger_types::BlockIndex; use serde::{Deserialize, Serialize}; + #[derive(CandidType, Deserialize)] + pub enum CreateSegmentArgs { + Satellite(CreateSatelliteArgs), + MissionControl(CreateMissionControlArgs), + Orbiter(CreateOrbiterArgs), + Canister(CreateCanisterArgs), + } + #[derive(CandidType, Deserialize)] pub struct CreateOrbiterArgs { pub user: UserId, @@ -139,6 +148,12 @@ pub mod interface { pub name: Option, } + #[derive(CandidType, Deserialize)] + pub struct CreateCanisterArgs { + pub subnet_id: Option, + pub name: Option, + } + #[derive(CandidType, Deserialize)] pub struct GetCreateCanisterFeeArgs { pub user: UserId, diff --git a/src/observatory/src/impls.rs b/src/observatory/src/impls.rs index 2ff485988..70adca92d 100644 --- a/src/observatory/src/impls.rs +++ b/src/observatory/src/impls.rs @@ -136,6 +136,7 @@ impl Notification { "https://console.juno.build/satellite/?s={}", self.segment.id ), + SegmentKind::Canister => "https://console.juno.build/canister".to_string(), } }