Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ src/
│ ├── security.rs
│ ├── ratelimit.rs
│ └── tag.rs
└── {tag,team,usage,metrics,chain,billing,bulk,stream,webhook,kv}.rs
└── {team,usage,metrics,chain,billing,stream,webhook,kv}.rs
```

## Adding a new subcommand: the playbook
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -180,8 +180,8 @@ qn usage by-endpoint --from 30d -o yaml
qn metrics account --period day --metric credits_over_time
qn chain list
qn billing invoices
qn bulk status --status paused ep-1 ep-2 ep-3
qn tag list
qn endpoint bulk pause ep-1 ep-2 ep-3
qn endpoint tag list
qn team list
```

Expand Down
9 changes: 0 additions & 9 deletions src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -81,10 +81,6 @@ pub enum Command {
#[command(visible_alias = "endpoints")]
Endpoint(commands::endpoint::Args),

/// Manage account-level tags.
#[command(visible_alias = "tags")]
Tag(commands::tag::Args),

/// Manage teams.
#[command(visible_alias = "teams")]
Team(commands::team::Args),
Expand All @@ -102,9 +98,6 @@ pub enum Command {
/// View invoices and payments.
Billing(commands::billing::Args),

/// Bulk operations across many endpoints.
Bulk(commands::bulk::Args),

/// Manage blockchain data streams.
#[command(visible_alias = "streams")]
Stream(commands::stream::Args),
Expand Down Expand Up @@ -162,13 +155,11 @@ impl Cli {
Command::Endpoint(args) => {
commands::endpoint::run(args, Ctx::from_global(global)?).await
}
Command::Tag(args) => commands::tag::run(args, Ctx::from_global(global)?).await,
Command::Team(args) => commands::team::run(args, Ctx::from_global(global)?).await,
Command::Usage(args) => commands::usage::run(args, Ctx::from_global(global)?).await,
Command::Metrics(args) => commands::metrics::run(args, Ctx::from_global(global)?).await,
Command::Chain(args) => commands::chain::run(args, Ctx::from_global(global)?).await,
Command::Billing(args) => commands::billing::run(args, Ctx::from_global(global)?).await,
Command::Bulk(args) => commands::bulk::run(args, Ctx::from_global(global)?).await,
Command::Stream(args) => commands::stream::run(args, Ctx::from_global(global)?).await,
Command::Webhook(args) => commands::webhook::run(args, Ctx::from_global(global)?).await,
Command::Kv(args) => commands::kv::run(args, Ctx::from_global(global)?).await,
Expand Down
63 changes: 24 additions & 39 deletions src/commands/bulk.rs → src/commands/endpoint/bulk.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
//! `qn bulk …` — bulk operations across many endpoints.

use clap::{Args as ClapArgs, Subcommand, ValueEnum};
//! `qn endpoint bulk …` — bulk operations across many endpoints.
//!
//! Note on the SDK mapping: the SDK exposes a single
//! `bulk_update_endpoint_status(ids, status)` call where `status` is a free-form
//! string. We split this into two CLI verbs (`pause` / `resume`) to mirror the
//! single-endpoint `qn endpoint pause` / `qn endpoint resume` verbs. The mapping is:
//! `pause -> "paused"`, `resume -> "active"`.

use clap::{Args as ClapArgs, Subcommand};
use comfy_table::Cell;
use quicknode_sdk::admin::{
BulkAddTagRequest, BulkRemoveTagRequest, BulkUpdateEndpointStatusRequest,
Expand All @@ -11,47 +17,25 @@ use crate::context::Ctx;
use crate::errors::CliError;
use crate::output::{new_table, set_header_bold, write_table, OutputCtx, Render};

#[derive(Debug, ClapArgs)]
pub struct Args {
#[command(subcommand)]
pub cmd: BulkCmd,
}

#[derive(Debug, Subcommand)]
pub enum BulkCmd {
/// Activate or pause many endpoints at once.
Status(StatusArgs),
/// Pause many endpoints at once.
Pause(IdsArgs),
/// Resume (activate) many endpoints at once.
Resume(IdsArgs),
/// Manage tags on many endpoints at once.
#[command(subcommand)]
Tag(TagCmd),
Tag(BulkTagCmd),
}

#[derive(Debug, ClapArgs)]
pub struct StatusArgs {
/// Target status.
#[arg(long, value_enum)]
pub status: BulkStatus,
pub struct IdsArgs {
/// Endpoint ids.
pub ids: Vec<String>,
}

#[derive(Debug, Clone, Copy, ValueEnum)]
pub enum BulkStatus {
Active,
Paused,
}

impl BulkStatus {
fn as_str(self) -> &'static str {
match self {
BulkStatus::Active => "active",
BulkStatus::Paused => "paused",
}
}
}

#[derive(Debug, Subcommand)]
pub enum TagCmd {
pub enum BulkTagCmd {
/// Apply a tag to many endpoints (creates the tag if missing).
Add(AddTagArgs),
/// Remove a tag from many endpoints (by numeric tag id).
Expand All @@ -76,21 +60,22 @@ pub struct RemoveTagArgs {
pub ids: Vec<String>,
}

pub async fn run(args: Args, ctx: Ctx) -> Result<(), CliError> {
match args.cmd {
BulkCmd::Status(a) => status(a, ctx).await,
BulkCmd::Tag(TagCmd::Add(a)) => tag_add(a, ctx).await,
BulkCmd::Tag(TagCmd::Remove(a)) => tag_remove(a, ctx).await,
pub async fn run(cmd: BulkCmd, ctx: Ctx) -> Result<(), CliError> {
match cmd {
BulkCmd::Pause(a) => set_status(a, "paused", ctx).await,
BulkCmd::Resume(a) => set_status(a, "active", ctx).await,
BulkCmd::Tag(BulkTagCmd::Add(a)) => tag_add(a, ctx).await,
BulkCmd::Tag(BulkTagCmd::Remove(a)) => tag_remove(a, ctx).await,
}
}

async fn status(a: StatusArgs, ctx: Ctx) -> Result<(), CliError> {
async fn set_status(a: IdsArgs, status: &str, ctx: Ctx) -> Result<(), CliError> {
if a.ids.is_empty() {
return Err(CliError::Arg("supply at least one endpoint id".to_string()));
}
let req = BulkUpdateEndpointStatusRequest {
ids: a.ids,
status: a.status.as_str().to_string(),
status: status.to_string(),
};
let resp = ctx.sdk.admin.bulk_update_endpoint_status(&req).await?;
crate::output::emit(&ctx.out, &BulkStatusView(resp))
Expand Down
11 changes: 9 additions & 2 deletions src/commands/endpoint/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,13 @@ use crate::context::Ctx;
use crate::errors::CliError;
use crate::time_arg;

mod bulk;
mod ratelimit;
pub(crate) mod render;
mod security;
mod tag;

pub use bulk::BulkCmd;
pub use ratelimit::RateLimitCmd;
pub use security::SecurityCmd;
pub use tag::TagCmd;
Expand Down Expand Up @@ -61,8 +63,8 @@ pub enum EndpointCmd {
/// Disable multichain on an endpoint.
DisableMultichain { id: String },

/// Manage tags on an endpoint (use `qn tag` for account-wide tag management).
#[command(subcommand)]
/// Manage endpoint tags (per-endpoint add/remove and account-wide list/rename/delete).
#[command(subcommand, visible_alias = "tags")]
Tag(TagCmd),

/// Manage endpoint security settings (tokens, referrers, IPs, JWTs, ...).
Expand All @@ -72,6 +74,10 @@ pub enum EndpointCmd {
/// Manage rate limits on an endpoint.
#[command(subcommand)]
RateLimit(RateLimitCmd),

/// Bulk operations across many endpoints.
#[command(subcommand)]
Bulk(BulkCmd),
}

#[derive(Debug, ClapArgs)]
Expand Down Expand Up @@ -190,6 +196,7 @@ pub async fn run(args: Args, ctx: Ctx) -> Result<(), CliError> {
EndpointCmd::Tag(c) => tag::run(c, ctx).await,
EndpointCmd::Security(c) => security::run(c, ctx).await,
EndpointCmd::RateLimit(c) => ratelimit::run(c, ctx).await,
EndpointCmd::Bulk(c) => bulk::run(c, ctx).await,
}
}

Expand Down
120 changes: 105 additions & 15 deletions src/commands/endpoint/tag.rs
Original file line number Diff line number Diff line change
@@ -1,23 +1,44 @@
//! `qn endpoint tag {add,remove}` — per-endpoint tag management.
//! `qn endpoint tag ` — endpoint tag management.
//!
//! For account-wide tag list/rename/delete, see `qn tag`.
//! Covers both account-wide tag CRUD (`list`, `rename`, `delete`) and
//! per-endpoint tag operations (`add`, `remove`). Tags only exist to label
//! endpoints, which is why the account-wide CRUD lives here too.

use clap::Subcommand;
use quicknode_sdk::admin::CreateTagRequest;
use comfy_table::Cell;
use quicknode_sdk::admin::{CreateTagRequest, RenameTagRequest};
use serde::Serialize;

use crate::confirm::{decide_without_prompt, prompt_yes_no, ConfirmCfg, Severity};
use crate::context::Ctx;
use crate::errors::CliError;
use crate::output::{new_table, set_header_bold, write_table, Render};

#[derive(Debug, Subcommand)]
pub enum TagCmd {
/// List every tag on the account with usage counts.
#[command(visible_alias = "ls")]
List,
/// Rename a tag.
Rename {
/// Tag id (numeric).
tag_id: i32,
/// New label.
label: String,
},
/// Delete a tag. The tag must not be applied to any endpoint.
Delete {
/// Tag id (numeric).
tag_id: i32,
},
/// Tag an endpoint. Creates the tag on the account if missing.
Add {
/// Endpoint id.
id: String,
/// Tag label.
label: String,
},
/// Remove a tag from an endpoint. `tag_id` is the numeric tag id from `qn tag list`.
/// Remove a tag from an endpoint. `tag_id` is the numeric tag id from `qn endpoint tag list`.
Remove {
/// Endpoint id.
id: String,
Expand All @@ -28,17 +49,86 @@ pub enum TagCmd {

pub async fn run(cmd: TagCmd, ctx: Ctx) -> Result<(), CliError> {
match cmd {
TagCmd::Add { id, label } => {
let req = CreateTagRequest {
label: Some(label.clone()),
};
ctx.sdk.admin.create_tag(&id, &req).await?;
ctx.out.note(&format!("✓ Tagged {id} with {label:?}"));
}
TagCmd::Remove { id, tag_id } => {
ctx.sdk.admin.delete_tag(&id, &tag_id).await?;
ctx.out.note(&format!("✓ Removed tag {tag_id} from {id}"));
}
TagCmd::List => list(ctx).await,
TagCmd::Rename { tag_id, label } => rename(tag_id, label, ctx).await,
TagCmd::Delete { tag_id } => delete(tag_id, ctx).await,
TagCmd::Add { id, label } => add(id, label, ctx).await,
TagCmd::Remove { id, tag_id } => remove(id, tag_id, ctx).await,
}
}

async fn list(ctx: Ctx) -> Result<(), CliError> {
let resp = ctx.sdk.admin.list_tags().await?;
crate::output::emit(&ctx.out, &TagsView(resp))
}

async fn rename(tag_id: i32, label: String, ctx: Ctx) -> Result<(), CliError> {
let req = RenameTagRequest {
label: label.clone(),
};
ctx.sdk.admin.rename_tag(tag_id, &req).await?;
ctx.out.note(&format!("✓ Renamed tag {tag_id} → {label:?}"));
Ok(())
}

async fn delete(tag_id: i32, ctx: Ctx) -> Result<(), CliError> {
let cfg = ConfirmCfg::new(
ctx.global.yes_count,
ctx.global.no_input,
ctx.out.stdout_is_tty,
);
let proceed = match decide_without_prompt(Severity::Mild, cfg)? {
true => true,
false => prompt_yes_no(&format!("Delete tag {tag_id}?"))?,
};
if !proceed {
return Err(CliError::Cancelled);
}
ctx.sdk.admin.delete_account_tag(tag_id).await?;
ctx.out.note(&format!("✓ Deleted tag {tag_id}"));
Ok(())
}

async fn add(id: String, label: String, ctx: Ctx) -> Result<(), CliError> {
let req = CreateTagRequest {
label: Some(label.clone()),
};
ctx.sdk.admin.create_tag(&id, &req).await?;
ctx.out.note(&format!("✓ Tagged {id} with {label:?}"));
Ok(())
}

async fn remove(id: String, tag_id: String, ctx: Ctx) -> Result<(), CliError> {
ctx.sdk.admin.delete_tag(&id, &tag_id).await?;
ctx.out.note(&format!("✓ Removed tag {tag_id} from {id}"));
Ok(())
}

#[derive(Serialize)]
struct TagsView(quicknode_sdk::admin::ListTagsResponse);

impl Render for TagsView {
fn render_table(
&self,
w: &mut dyn std::io::Write,
ctx: &crate::output::OutputCtx,
) -> std::io::Result<()> {
let data = match &self.0.data {
Some(d) => d,
None => {
writeln!(w, "(no tag data)")?;
return Ok(());
}
};
let mut t = new_table(ctx);
set_header_bold(&mut t, ctx, vec!["ID", "LABEL", "USAGE"]);
for tg in &data.tags {
t.add_row(vec![
Cell::new(tg.id),
Cell::new(&tg.label),
Cell::new(tg.usage_count),
]);
}
write_table(w, &t)
}
}
2 changes: 0 additions & 2 deletions src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,11 @@

pub mod auth;
pub mod billing;
pub mod bulk;
pub mod chain;
pub mod endpoint;
pub mod kv;
pub mod metrics;
pub mod stream;
pub mod tag;
pub mod team;
pub mod usage;
pub mod webhook;
Loading
Loading