/**
* Azure Storage Dashboard — MCP Apps client-side UI.
*
* Uses the App class from @modelcontextprotocol/ext-apps to communicate
* with the MCP host and call server tools for full CRUD operations on
* Azure Storage accounts, containers, and blobs.
*/
import {
App,
applyDocumentTheme,
applyHostStyleVariables,
applyHostFonts,
type McpUiHostContext,
} from "@modelcontextprotocol/ext-apps";
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
import "./mcp-app.css";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
interface AccountSummary {
name: string;
resourceGroup: string;
location: string;
kind: string;
sku: string;
accessTier: string;
tags: Record<string, string>;
provisioningState: string;
}
interface ContainerInfo {
name: string;
lastModified: string;
publicAccess: string;
leaseState: string;
metadata: Record<string, string>;
}
interface BlobInfo {
name: string;
contentLength: number;
contentType: string;
lastModified: string;
accessTier: string;
blobType: string;
}
interface StorageAccountDetails {
type: string;
name: string;
resourceGroup: string;
location: string;
kind: string;
sku: string;
accessTier: string;
createdDate: string;
provisioningState: string;
primaryEndpoints: {
blob: string;
file: string;
queue: string;
table: string;
};
httpsOnly: boolean;
minimumTlsVersion: string;
allowBlobPublicAccess: boolean;
networkRuleSet: {
defaultAction: string;
virtualNetworkRules: number;
ipRules: number;
} | null;
encryption: {
services: string[];
keySource: string;
} | null;
tags: Record<string, string>;
}
interface SasTokenResult {
type: string;
accountName: string;
containerName: string;
blobName: string | null;
permissions: string;
expiryHours: number;
token: string;
url: string;
expiresAt: string;
}
interface DashboardState {
view: "loading" | "accounts" | "containers" | "blobs";
accounts: AccountSummary[];
subscriptionId: string;
selectedAccount: { name: string; resourceGroup: string } | null;
selectedContainer: string | null;
accountDetails: StorageAccountDetails | null;
containers: ContainerInfo[];
blobs: BlobInfo[];
}
// ---------------------------------------------------------------------------
// State
// ---------------------------------------------------------------------------
const state: DashboardState = {
view: "loading",
accounts: [],
subscriptionId: "",
selectedAccount: null,
selectedContainer: null,
accountDetails: null,
containers: [],
blobs: [],
};
// ---------------------------------------------------------------------------
// DOM References
// ---------------------------------------------------------------------------
const loadingEl = document.getElementById("loading")!;
const errorView = document.getElementById("error-view")!;
const errorMessage = document.getElementById("error-message")!;
const breadcrumb = document.getElementById("breadcrumb")!;
const accountsView = document.getElementById("accounts-view")!;
const containersView = document.getElementById("containers-view")!;
const blobsView = document.getElementById("blobs-view")!;
const dashboardHeader = document.getElementById("dashboard-header")!;
const summaryBar = document.getElementById("summary-bar")!;
const accountsGrid = document.getElementById("accounts-grid")!;
const accountProperties = document.getElementById("account-properties")!;
const containersToolbar = document.getElementById("containers-toolbar")!;
const containersList = document.getElementById("containers-list")!;
const blobsToolbar = document.getElementById("blobs-toolbar")!;
const blobsList = document.getElementById("blobs-list")!;
// Modals
const createContainerModal = document.getElementById("create-container-modal")!;
const createContainerForm = document.getElementById("create-container-form")!;
const uploadBlobModal = document.getElementById("upload-blob-modal")!;
const uploadBlobForm = document.getElementById("upload-blob-form")!;
const sasModal = document.getElementById("sas-modal")!;
const sasFormContainer = document.getElementById("sas-form-container")!;
const sasResultContainer = document.getElementById("sas-result-container")!;
const blobViewerModal = document.getElementById("blob-viewer-modal")!;
const blobViewerContent = document.getElementById("blob-viewer-content")!;
const confirmModal = document.getElementById("confirm-modal")!;
const confirmContent = document.getElementById("confirm-content")!;
// ---------------------------------------------------------------------------
// MCP App Initialization
// ---------------------------------------------------------------------------
const app = new App({ name: "Azure Storage Dashboard", version: "1.0.0" });
function handleHostContextChanged(ctx: McpUiHostContext) {
if (ctx.theme) applyDocumentTheme(ctx.theme);
if (ctx.styles?.variables) applyHostStyleVariables(ctx.styles.variables);
if (ctx.styles?.css?.fonts) applyHostFonts(ctx.styles.css.fonts);
}
app.ontoolresult = (result: CallToolResult) => {
const data = result.structuredContent as Record<string, unknown> | undefined;
if (data?.type === "storage_accounts_list") {
state.accounts = data.accounts as AccountSummary[];
state.subscriptionId = data.subscriptionId as string;
state.view = "accounts";
render();
}
};
app.ontoolinput = (params: unknown) => {
console.info("Received tool input:", params);
};
app.ontoolcancelled = () => {
console.info("Tool call cancelled");
};
app.onerror = console.error;
app.onhostcontextchanged = handleHostContextChanged;
app.connect().then(() => {
const ctx = app.getHostContext();
if (ctx) handleHostContextChanged(ctx);
});
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function escapeHtml(text: string): string {
const div = document.createElement("div");
div.textContent = text;
return div.innerHTML;
}
function formatSize(bytes: number): string {
if (bytes >= 1073741824) return `${(bytes / 1073741824).toFixed(1)} GB`;
if (bytes >= 1048576) return `${(bytes / 1048576).toFixed(1)} MB`;
if (bytes >= 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${bytes} B`;
}
function formatDate(iso: string | undefined): string {
if (!iso) return "—";
return new Date(iso).toLocaleDateString("en-US", {
year: "numeric",
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}
const LOCATION_NAMES: Record<string, string> = {
westus2: "West US 2",
westus: "West US",
westus3: "West US 3",
eastus: "East US",
eastus2: "East US 2",
centralus: "Central US",
northcentralus: "North Central US",
southcentralus: "South Central US",
westeurope: "West Europe",
northeurope: "North Europe",
uksouth: "UK South",
ukwest: "UK West",
australiaeast: "Australia East",
southeastasia: "Southeast Asia",
eastasia: "East Asia",
japaneast: "Japan East",
canadacentral: "Canada Central",
brazilsouth: "Brazil South",
koreacentral: "Korea Central",
francecentral: "France Central",
germanywestcentral: "Germany West Central",
norwayeast: "Norway East",
switzerlandnorth: "Switzerland North",
uaenorth: "UAE North",
southafricanorth: "South Africa North",
centralindia: "Central India",
westcentralus: "West Central US",
};
function locationName(code: string | undefined): string {
if (!code) return "Unknown";
return LOCATION_NAMES[code] ?? code;
}
const SKU_NAMES: Record<string, string> = {
Standard_RAGRS: "Standard RA-GRS",
Standard_LRS: "Standard LRS",
Standard_GRS: "Standard GRS",
Standard_ZRS: "Standard ZRS",
Standard_GZRS: "Standard GZRS",
Standard_RAGZRS: "Standard RA-GZRS",
Premium_LRS: "Premium LRS",
Premium_ZRS: "Premium ZRS",
};
function skuName(code: string | undefined): string {
if (!code) return "Unknown";
return SKU_NAMES[code] ?? code;
}
function permissionLabel(char: string): string {
const map: Record<string, string> = {
r: "Read",
a: "Add",
c: "Create",
w: "Write",
d: "Delete",
l: "List",
};
return map[char] ?? char;
}
async function callTool(
name: string,
args: Record<string, unknown>,
): Promise<Record<string, unknown> | null> {
try {
const result = await app.callServerTool({ name, arguments: args });
if (result.isError) {
const text =
(result.content as Array<{ type: string; text: string }>)?.[0]?.text ??
"Tool call failed";
showError(text);
return null;
}
return (result.structuredContent as Record<string, unknown>) ?? null;
} catch (err) {
showError(`Request failed: ${err}`);
return null;
}
}
function showError(msg: string): void {
errorMessage.textContent = msg;
errorView.classList.remove("hidden");
setTimeout(() => errorView.classList.add("hidden"), 8000);
}
function copyToClipboard(text: string, button: HTMLButtonElement): void {
navigator.clipboard.writeText(text).then(
() => {
const original = button.textContent;
button.textContent = "Copied!";
button.classList.add("copied");
setTimeout(() => {
button.textContent = original;
button.classList.remove("copied");
}, 2000);
},
() => {
const range = document.createRange();
const el = button.parentElement!;
range.selectNodeContents(el);
const selection = window.getSelection()!;
selection.removeAllRanges();
selection.addRange(range);
},
);
}
// Azure icon SVG
const AZURE_ICON = `<svg class="azure-icon" viewBox="0 0 96 96" xmlns="http://www.w3.org/2000/svg">
<path d="M35.5 11L57.2 68.8 19.6 76.6 35.5 11z" fill="#0078d4"/>
<path d="M50.6 21.7L67.3 58.2 31.7 85 76.4 85 50.6 21.7z" fill="#50e6ff"/>
<path d="M38.5 26.5L56.7 68.8 31.7 85 38.5 26.5z" fill="url(#a)" opacity="0.55"/>
<defs>
<linearGradient id="a" x1="38.5" y1="26.5" x2="43" y2="85" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#000" stop-opacity=".3"/>
<stop offset="1" stop-color="#000" stop-opacity="0"/>
</linearGradient>
</defs>
</svg>`;
// ---------------------------------------------------------------------------
// Modal helpers
// ---------------------------------------------------------------------------
function openModal(modal: HTMLElement): void {
modal.classList.remove("hidden");
}
function closeModal(modal: HTMLElement): void {
modal.classList.add("hidden");
}
function setupModalBackdropClose(modal: HTMLElement): void {
modal.querySelector(".modal-backdrop")!.addEventListener("click", () => {
closeModal(modal);
});
}
// Set up backdrop close for all modals
setupModalBackdropClose(createContainerModal);
setupModalBackdropClose(uploadBlobModal);
setupModalBackdropClose(sasModal);
setupModalBackdropClose(blobViewerModal);
setupModalBackdropClose(confirmModal);
// ---------------------------------------------------------------------------
// Rendering — Main
// ---------------------------------------------------------------------------
function render(): void {
loadingEl.classList.toggle("hidden", state.view !== "loading");
accountsView.classList.toggle("hidden", state.view !== "accounts");
containersView.classList.toggle("hidden", state.view !== "containers");
blobsView.classList.toggle("hidden", state.view !== "blobs");
renderBreadcrumb();
if (state.view === "accounts") renderAccounts();
if (state.view === "containers") renderContainers();
if (state.view === "blobs") renderBlobs();
}
// ---------------------------------------------------------------------------
// Breadcrumb
// ---------------------------------------------------------------------------
function renderBreadcrumb(): void {
if (state.view === "loading") {
breadcrumb.classList.add("hidden");
return;
}
breadcrumb.classList.remove("hidden");
const items: string[] = [];
items.push(
state.view === "accounts"
? `<span class="breadcrumb-item active">Storage Accounts</span>`
: `<span class="breadcrumb-item clickable" id="bc-accounts">Storage Accounts</span>`,
);
if (
(state.view === "containers" || state.view === "blobs") &&
state.selectedAccount
) {
items.push(`<span class="breadcrumb-sep">/</span>`);
items.push(
state.view === "containers"
? `<span class="breadcrumb-item active">${escapeHtml(state.selectedAccount.name)}</span>`
: `<span class="breadcrumb-item clickable" id="bc-containers">${escapeHtml(state.selectedAccount.name)}</span>`,
);
}
if (state.view === "blobs" && state.selectedContainer) {
items.push(`<span class="breadcrumb-sep">/</span>`);
items.push(
`<span class="breadcrumb-item active">${escapeHtml(state.selectedContainer)}</span>`,
);
}
breadcrumb.innerHTML = items.join("");
// Attach click handlers
const bcAccounts = document.getElementById("bc-accounts");
if (bcAccounts) {
bcAccounts.addEventListener("click", () => {
state.view = "accounts";
state.selectedAccount = null;
state.selectedContainer = null;
render();
});
}
const bcContainers = document.getElementById("bc-containers");
if (bcContainers) {
bcContainers.addEventListener("click", () => {
if (state.selectedAccount) {
state.view = "containers";
state.selectedContainer = null;
render();
}
});
}
}
// ---------------------------------------------------------------------------
// View 1: Storage Accounts
// ---------------------------------------------------------------------------
function renderAccounts(): void {
dashboardHeader.innerHTML = `
${AZURE_ICON}
<div>
<div class="header-title">Azure Storage Accounts</div>
<div class="header-sub">${escapeHtml(state.subscriptionId)}</div>
</div>`;
const accountCount = state.accounts.length;
summaryBar.innerHTML = `
<div class="summary-stat">
<span class="summary-value">${accountCount}</span>
<span class="summary-label">Accounts</span>
</div>`;
accountsGrid.innerHTML = state.accounts
.map(
(account) => `
<div class="account-card" data-account="${escapeHtml(account.name)}" data-rg="${escapeHtml(account.resourceGroup)}">
<div class="card-header">
<div>
<div class="card-name">${escapeHtml(account.name)}</div>
<div class="card-rg">${escapeHtml(account.resourceGroup)}</div>
</div>
<span class="card-arrow">›</span>
</div>
<div class="card-body">
<span class="badge badge-location">${escapeHtml(locationName(account.location))}</span>
<span class="badge">${escapeHtml(skuName(account.sku))}</span>
<span class="badge">${escapeHtml(account.kind ?? "")}</span>
${account.accessTier ? `<span class="badge">${escapeHtml(account.accessTier)}</span>` : ""}
</div>
<div class="card-stats">
<span class="card-stat">${escapeHtml(account.provisioningState ?? "")}</span>
</div>
${
account.tags && Object.keys(account.tags).length > 0
? `<div class="card-tags">${Object.entries(account.tags)
.map(
([k, v]) =>
`<span class="tag">${escapeHtml(k)}: ${escapeHtml(v)}</span>`,
)
.join("")}</div>`
: ""
}
</div>`,
)
.join("");
accountsGrid
.querySelectorAll<HTMLElement>(".account-card")
.forEach((card) => {
card.addEventListener("click", () => {
const name = card.dataset.account!;
const rg = card.dataset.rg!;
navigateToContainers(name, rg);
});
});
}
// ---------------------------------------------------------------------------
// View 2: Containers
// ---------------------------------------------------------------------------
async function navigateToContainers(
accountName: string,
resourceGroup: string,
): Promise<void> {
state.selectedAccount = { name: accountName, resourceGroup };
state.selectedContainer = null;
state.view = "containers";
state.containers = [];
state.accountDetails = null;
render();
accountProperties.innerHTML = `<div class="status-loading"><div class="spinner-sm"></div> Loading account details...</div>`;
containersList.innerHTML = "";
// Fetch account details and containers in parallel
const [detailsResult, containersResult] = await Promise.all([
callTool("get_storage_account", {
accountName,
resourceGroupName: resourceGroup,
}),
callTool("list_containers", { accountName }),
]);
if (detailsResult) {
state.accountDetails = detailsResult as unknown as StorageAccountDetails;
}
if (containersResult) {
state.containers = (containersResult.containers as ContainerInfo[]) ?? [];
}
render();
}
function renderContainers(): void {
// Account properties
if (state.accountDetails) {
const acct = state.accountDetails;
accountProperties.innerHTML = `
<div class="properties-grid">
<div class="prop-item">
<span class="prop-label">Location</span>
<span class="prop-value">${escapeHtml(locationName(acct.location))}</span>
</div>
<div class="prop-item">
<span class="prop-label">Kind</span>
<span class="prop-value">${escapeHtml(acct.kind ?? "")}</span>
</div>
<div class="prop-item">
<span class="prop-label">SKU</span>
<span class="prop-value">${escapeHtml(skuName(acct.sku))}</span>
</div>
<div class="prop-item">
<span class="prop-label">Access Tier</span>
<span class="prop-value">${escapeHtml(acct.accessTier ?? "—")}</span>
</div>
<div class="prop-item">
<span class="prop-label">Created</span>
<span class="prop-value">${escapeHtml(formatDate(acct.createdDate))}</span>
</div>
<div class="prop-item">
<span class="prop-label">HTTPS Only</span>
<span class="prop-value ${acct.httpsOnly ? "success" : "warning"}">${acct.httpsOnly ? "Yes" : "No"}</span>
</div>
<div class="prop-item">
<span class="prop-label">Min TLS Version</span>
<span class="prop-value">${escapeHtml(acct.minimumTlsVersion ?? "—")}</span>
</div>
<div class="prop-item">
<span class="prop-label">Public Blob Access</span>
<span class="prop-value ${acct.allowBlobPublicAccess ? "warning" : "success"}">${acct.allowBlobPublicAccess ? "Allowed" : "Disabled"}</span>
</div>
${
acct.networkRuleSet
? `<div class="prop-item">
<span class="prop-label">Network Default</span>
<span class="prop-value ${acct.networkRuleSet.defaultAction === "Deny" ? "success" : "warning"}">${escapeHtml(acct.networkRuleSet.defaultAction)}</span>
</div>
<div class="prop-item">
<span class="prop-label">Network Rules</span>
<span class="prop-value">${acct.networkRuleSet.virtualNetworkRules} VNet, ${acct.networkRuleSet.ipRules} IP</span>
</div>`
: ""
}
${
acct.encryption
? `<div class="prop-item">
<span class="prop-label">Encryption</span>
<span class="prop-value">${escapeHtml(acct.encryption.services.join(", "))}</span>
</div>
<div class="prop-item">
<span class="prop-label">Key Source</span>
<span class="prop-value">${escapeHtml(acct.encryption.keySource ?? "—")}</span>
</div>`
: ""
}
</div>`;
} else {
accountProperties.innerHTML = `<div class="status-loading"><div class="spinner-sm"></div> Loading...</div>`;
}
// Containers toolbar
containersToolbar.innerHTML = `
<div class="toolbar">
<h3 class="section-title">Containers (${state.containers.length})</h3>
<div class="toolbar-actions">
<button class="btn btn-primary" id="btn-create-container">+ Create Container</button>
<button class="btn btn-secondary" id="btn-refresh-containers">Refresh</button>
</div>
</div>`;
document
.getElementById("btn-create-container")!
.addEventListener("click", openCreateContainerModal);
document
.getElementById("btn-refresh-containers")!
.addEventListener("click", async () => {
if (state.selectedAccount) {
const result = await callTool("list_containers", {
accountName: state.selectedAccount.name,
});
if (result) {
state.containers =
(result.containers as ContainerInfo[]) ?? [];
render();
}
}
});
// Containers table
if (state.containers.length === 0) {
containersList.innerHTML = `<div class="empty-state">No containers found. Create one to get started.</div>`;
return;
}
containersList.innerHTML = `
<table class="data-table">
<thead>
<tr>
<th>Name</th>
<th>Public Access</th>
<th>Last Modified</th>
<th>Lease State</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
${state.containers
.map(
(c) => `
<tr>
<td>
<span class="link-text" data-container="${escapeHtml(c.name)}">${escapeHtml(c.name)}</span>
</td>
<td>${escapeHtml(c.publicAccess ?? "none")}</td>
<td>${escapeHtml(formatDate(c.lastModified))}</td>
<td>${escapeHtml(c.leaseState ?? "—")}</td>
<td class="actions-cell">
<button class="btn btn-sm btn-primary sas-btn" data-container="${escapeHtml(c.name)}">SAS</button>
<button class="btn btn-sm btn-danger delete-container-btn" data-container="${escapeHtml(c.name)}">Delete</button>
</td>
</tr>`,
)
.join("")}
</tbody>
</table>`;
// Click container name to drill into blobs
containersList
.querySelectorAll<HTMLElement>(".link-text")
.forEach((el) => {
el.addEventListener("click", () => {
navigateToBlobs(
state.selectedAccount!.name,
el.dataset.container!,
);
});
});
// SAS buttons
containersList
.querySelectorAll<HTMLElement>(".sas-btn")
.forEach((btn) => {
btn.addEventListener("click", (e) => {
e.stopPropagation();
openSasModal(
state.selectedAccount!.name,
btn.dataset.container!,
);
});
});
// Delete buttons
containersList
.querySelectorAll<HTMLElement>(".delete-container-btn")
.forEach((btn) => {
btn.addEventListener("click", (e) => {
e.stopPropagation();
confirmDeleteContainer(btn.dataset.container!);
});
});
}
// ---------------------------------------------------------------------------
// View 3: Blobs
// ---------------------------------------------------------------------------
async function navigateToBlobs(
accountName: string,
containerName: string,
): Promise<void> {
state.selectedContainer = containerName;
state.view = "blobs";
state.blobs = [];
render();
blobsList.innerHTML = `<div class="status-loading"><div class="spinner-sm"></div> Loading blobs...</div>`;
const result = await callTool("list_blobs", {
accountName,
containerName,
});
if (result) {
state.blobs = (result.blobs as BlobInfo[]) ?? [];
}
render();
}
function renderBlobs(): void {
const accountName = state.selectedAccount!.name;
const containerName = state.selectedContainer!;
blobsToolbar.innerHTML = `
<div class="toolbar">
<h3 class="section-title">Blobs</h3>
<div class="toolbar-actions">
<button class="btn btn-primary" id="btn-upload-blob">Upload Blob</button>
<button class="btn btn-secondary" id="btn-container-sas">Container SAS</button>
<button class="btn btn-secondary" id="btn-refresh-blobs">Refresh</button>
</div>
</div>`;
document
.getElementById("btn-upload-blob")!
.addEventListener("click", openUploadBlobModal);
document
.getElementById("btn-container-sas")!
.addEventListener("click", () => {
openSasModal(accountName, containerName);
});
document
.getElementById("btn-refresh-blobs")!
.addEventListener("click", async () => {
const result = await callTool("list_blobs", {
accountName,
containerName,
});
if (result) {
state.blobs = (result.blobs as BlobInfo[]) ?? [];
render();
}
});
if (state.blobs.length === 0) {
blobsList.innerHTML = `<div class="empty-state">No blobs found. Upload one to get started.</div>`;
return;
}
blobsList.innerHTML = `
<table class="data-table">
<thead>
<tr>
<th>Name</th>
<th>Type</th>
<th>Size</th>
<th>Content Type</th>
<th>Last Modified</th>
<th>Tier</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
${state.blobs
.map(
(b) => `
<tr>
<td><span class="blob-name">${escapeHtml(b.name)}</span></td>
<td>${escapeHtml(b.blobType ?? "—")}</td>
<td>${formatSize(b.contentLength ?? 0)}</td>
<td>${escapeHtml(b.contentType ?? "—")}</td>
<td>${escapeHtml(formatDate(b.lastModified))}</td>
<td>${escapeHtml(b.accessTier ?? "—")}</td>
<td class="actions-cell">
<button class="btn btn-sm btn-secondary download-btn" data-blob="${escapeHtml(b.name)}">Download</button>
<button class="btn btn-sm btn-primary blob-sas-btn" data-blob="${escapeHtml(b.name)}">SAS</button>
<button class="btn btn-sm btn-danger delete-blob-btn" data-blob="${escapeHtml(b.name)}">Delete</button>
</td>
</tr>`,
)
.join("")}
</tbody>
</table>`;
// Download buttons
blobsList
.querySelectorAll<HTMLElement>(".download-btn")
.forEach((btn) => {
btn.addEventListener("click", () => {
handleDownloadBlob(accountName, containerName, btn.dataset.blob!);
});
});
// Blob SAS buttons
blobsList
.querySelectorAll<HTMLElement>(".blob-sas-btn")
.forEach((btn) => {
btn.addEventListener("click", () => {
openSasModal(accountName, containerName, btn.dataset.blob!);
});
});
// Delete buttons
blobsList
.querySelectorAll<HTMLElement>(".delete-blob-btn")
.forEach((btn) => {
btn.addEventListener("click", () => {
confirmDeleteBlob(accountName, containerName, btn.dataset.blob!);
});
});
}
// ---------------------------------------------------------------------------
// Create Container Modal
// ---------------------------------------------------------------------------
function openCreateContainerModal(): void {
createContainerForm.innerHTML = `
<div class="modal-title">Create Container</div>
<div class="form-group">
<label class="form-label">Container Name</label>
<input class="form-input" type="text" id="new-container-name" placeholder="my-container" />
</div>
<div class="form-group">
<label class="form-label">Public Access Level</label>
<select class="form-input" id="new-container-access">
<option value="none">None (Private)</option>
<option value="blob">Blob (anonymous read for blobs)</option>
<option value="container">Container (anonymous read for container & blobs)</option>
</select>
</div>
<div class="form-actions">
<button class="btn btn-secondary" id="create-container-cancel">Cancel</button>
<button class="btn btn-primary" id="create-container-submit">Create</button>
</div>`;
document
.getElementById("create-container-cancel")!
.addEventListener("click", () => closeModal(createContainerModal));
document
.getElementById("create-container-submit")!
.addEventListener("click", handleCreateContainer);
openModal(createContainerModal);
}
async function handleCreateContainer(): Promise<void> {
const nameInput = document.getElementById(
"new-container-name",
) as HTMLInputElement;
const accessSelect = document.getElementById(
"new-container-access",
) as HTMLSelectElement;
const containerName = nameInput.value.trim();
if (!containerName) {
nameInput.classList.add("input-error");
return;
}
const submitBtn = document.getElementById(
"create-container-submit",
) as HTMLButtonElement;
submitBtn.disabled = true;
submitBtn.textContent = "Creating...";
const result = await callTool("create_container", {
accountName: state.selectedAccount!.name,
containerName,
publicAccess: accessSelect.value,
});
if (result) {
closeModal(createContainerModal);
// Refresh containers list
const listResult = await callTool("list_containers", {
accountName: state.selectedAccount!.name,
});
if (listResult) {
state.containers =
(listResult.containers as ContainerInfo[]) ?? [];
render();
}
} else {
submitBtn.disabled = false;
submitBtn.textContent = "Create";
}
}
// ---------------------------------------------------------------------------
// Upload Blob Modal
// ---------------------------------------------------------------------------
function openUploadBlobModal(): void {
uploadBlobForm.innerHTML = `
<div class="modal-title">Upload Blob</div>
<div class="form-group">
<label class="form-label">Upload Method</label>
<div class="toggle-group">
<button class="toggle-btn active" data-method="file" id="toggle-file">File</button>
<button class="toggle-btn" data-method="text" id="toggle-text">Text</button>
</div>
</div>
<div id="upload-file-section">
<div class="form-group">
<label class="form-label">Select File</label>
<div class="file-drop-zone" id="file-drop-zone">
<input type="file" id="file-input" class="file-input-hidden" />
<div class="file-drop-text">Click to select or drag a file here</div>
<div class="file-drop-name hidden" id="file-drop-name"></div>
</div>
</div>
</div>
<div id="upload-text-section" class="hidden">
<div class="form-group">
<label class="form-label">Blob Name</label>
<input class="form-input" type="text" id="text-blob-name" placeholder="example.txt" />
</div>
<div class="form-group">
<label class="form-label">Content Type</label>
<input class="form-input" type="text" id="text-content-type" value="text/plain" />
</div>
<div class="form-group">
<label class="form-label">Content</label>
<textarea class="form-input form-textarea" id="text-blob-content" rows="8" placeholder="Paste text content here..."></textarea>
</div>
</div>
<div class="form-actions">
<button class="btn btn-secondary" id="upload-cancel">Cancel</button>
<button class="btn btn-primary" id="upload-submit">Upload</button>
</div>`;
// Toggle between file and text upload
const toggleFile = document.getElementById("toggle-file")!;
const toggleText = document.getElementById("toggle-text")!;
const fileSection = document.getElementById("upload-file-section")!;
const textSection = document.getElementById("upload-text-section")!;
toggleFile.addEventListener("click", () => {
toggleFile.classList.add("active");
toggleText.classList.remove("active");
fileSection.classList.remove("hidden");
textSection.classList.add("hidden");
});
toggleText.addEventListener("click", () => {
toggleText.classList.add("active");
toggleFile.classList.remove("active");
textSection.classList.remove("hidden");
fileSection.classList.add("hidden");
});
// File input
const fileInput = document.getElementById("file-input") as HTMLInputElement;
const fileDropZone = document.getElementById("file-drop-zone")!;
const fileDropName = document.getElementById("file-drop-name")!;
fileDropZone.addEventListener("click", () => fileInput.click());
fileInput.addEventListener("change", () => {
if (fileInput.files?.[0]) {
fileDropName.textContent = fileInput.files[0].name;
fileDropName.classList.remove("hidden");
}
});
// Drag and drop
fileDropZone.addEventListener("dragover", (e) => {
e.preventDefault();
fileDropZone.classList.add("drag-over");
});
fileDropZone.addEventListener("dragleave", () => {
fileDropZone.classList.remove("drag-over");
});
fileDropZone.addEventListener("drop", (e) => {
e.preventDefault();
fileDropZone.classList.remove("drag-over");
const files = (e as DragEvent).dataTransfer?.files;
if (files?.[0]) {
fileInput.files = files;
fileDropName.textContent = files[0].name;
fileDropName.classList.remove("hidden");
}
});
document
.getElementById("upload-cancel")!
.addEventListener("click", () => closeModal(uploadBlobModal));
document
.getElementById("upload-submit")!
.addEventListener("click", handleUploadBlob);
openModal(uploadBlobModal);
}
async function handleUploadBlob(): Promise<void> {
const isFileMode = document
.getElementById("toggle-file")!
.classList.contains("active");
const submitBtn = document.getElementById(
"upload-submit",
) as HTMLButtonElement;
const accountName = state.selectedAccount!.name;
const containerName = state.selectedContainer!;
submitBtn.disabled = true;
submitBtn.textContent = "Uploading...";
if (isFileMode) {
const fileInput = document.getElementById("file-input") as HTMLInputElement;
const file = fileInput.files?.[0];
if (!file) {
showError("Please select a file.");
submitBtn.disabled = false;
submitBtn.textContent = "Upload";
return;
}
const reader = new FileReader();
reader.onload = async () => {
const base64 = (reader.result as string).split(",")[1];
const result = await callTool("upload_blob", {
accountName,
containerName,
blobName: file.name,
content: base64,
contentType: file.type || "application/octet-stream",
encoding: "base64",
});
if (result) {
closeModal(uploadBlobModal);
await refreshBlobs();
} else {
submitBtn.disabled = false;
submitBtn.textContent = "Upload";
}
};
reader.readAsDataURL(file);
} else {
const blobName = (
document.getElementById("text-blob-name") as HTMLInputElement
).value.trim();
const contentType = (
document.getElementById("text-content-type") as HTMLInputElement
).value.trim();
const content = (
document.getElementById("text-blob-content") as HTMLTextAreaElement
).value;
if (!blobName) {
showError("Please enter a blob name.");
submitBtn.disabled = false;
submitBtn.textContent = "Upload";
return;
}
const result = await callTool("upload_blob", {
accountName,
containerName,
blobName,
content,
contentType: contentType || "text/plain",
encoding: "utf-8",
});
if (result) {
closeModal(uploadBlobModal);
await refreshBlobs();
} else {
submitBtn.disabled = false;
submitBtn.textContent = "Upload";
}
}
}
async function refreshBlobs(): Promise<void> {
if (state.selectedAccount && state.selectedContainer) {
const result = await callTool("list_blobs", {
accountName: state.selectedAccount.name,
containerName: state.selectedContainer,
});
if (result) {
state.blobs = (result.blobs as BlobInfo[]) ?? [];
render();
}
}
}
// ---------------------------------------------------------------------------
// Download Blob
// ---------------------------------------------------------------------------
async function handleDownloadBlob(
accountName: string,
containerName: string,
blobName: string,
): Promise<void> {
const result = await callTool("download_blob", {
accountName,
containerName,
blobName,
});
if (!result) return;
const text = result.text as string | undefined;
const base64 = result.contentBase64 as string;
const contentType = result.contentType as string;
if (text) {
// Show text content in viewer modal
blobViewerContent.innerHTML = `
<div class="modal-title">${escapeHtml(blobName)}</div>
<div class="blob-viewer-info">
<span>${escapeHtml(contentType)}</span>
<span>${formatSize(result.contentLength as number)}</span>
</div>
<pre class="blob-viewer-pre">${escapeHtml(text)}</pre>
<div class="form-actions">
<button class="btn btn-secondary" id="blob-viewer-close">Close</button>
<button class="btn btn-primary" id="blob-viewer-save">Save to File</button>
</div>`;
document
.getElementById("blob-viewer-close")!
.addEventListener("click", () => closeModal(blobViewerModal));
document
.getElementById("blob-viewer-save")!
.addEventListener("click", () => {
triggerDownload(base64, contentType, blobName);
});
openModal(blobViewerModal);
} else {
triggerDownload(base64, contentType, blobName);
}
}
function triggerDownload(
base64: string,
contentType: string,
blobName: string,
): void {
const bytes = Uint8Array.from(atob(base64), (c) => c.charCodeAt(0));
const blob = new Blob([bytes], { type: contentType });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = blobName.split("/").pop() || blobName;
a.click();
URL.revokeObjectURL(url);
}
// ---------------------------------------------------------------------------
// Delete Container
// ---------------------------------------------------------------------------
function confirmDeleteContainer(containerName: string): void {
confirmContent.innerHTML = `
<div class="modal-title confirm-danger">Delete Container</div>
<p class="confirm-text">Are you sure you want to delete container <strong>${escapeHtml(containerName)}</strong>? This action cannot be undone and all blobs within will be lost.</p>
<div class="form-actions">
<button class="btn btn-secondary" id="confirm-cancel">Cancel</button>
<button class="btn btn-danger" id="confirm-delete">Delete</button>
</div>`;
document
.getElementById("confirm-cancel")!
.addEventListener("click", () => closeModal(confirmModal));
document
.getElementById("confirm-delete")!
.addEventListener("click", async () => {
const deleteBtn = document.getElementById(
"confirm-delete",
) as HTMLButtonElement;
deleteBtn.disabled = true;
deleteBtn.textContent = "Deleting...";
const result = await callTool("delete_container", {
accountName: state.selectedAccount!.name,
containerName,
});
closeModal(confirmModal);
if (result) {
const listResult = await callTool("list_containers", {
accountName: state.selectedAccount!.name,
});
if (listResult) {
state.containers =
(listResult.containers as ContainerInfo[]) ?? [];
render();
}
}
});
openModal(confirmModal);
}
// ---------------------------------------------------------------------------
// Delete Blob
// ---------------------------------------------------------------------------
function confirmDeleteBlob(
accountName: string,
containerName: string,
blobName: string,
): void {
confirmContent.innerHTML = `
<div class="modal-title confirm-danger">Delete Blob</div>
<p class="confirm-text">Are you sure you want to delete blob <strong>${escapeHtml(blobName)}</strong>? This action cannot be undone.</p>
<div class="form-actions">
<button class="btn btn-secondary" id="confirm-cancel">Cancel</button>
<button class="btn btn-danger" id="confirm-delete">Delete</button>
</div>`;
document
.getElementById("confirm-cancel")!
.addEventListener("click", () => closeModal(confirmModal));
document
.getElementById("confirm-delete")!
.addEventListener("click", async () => {
const deleteBtn = document.getElementById(
"confirm-delete",
) as HTMLButtonElement;
deleteBtn.disabled = true;
deleteBtn.textContent = "Deleting...";
const result = await callTool("delete_blob", {
accountName,
containerName,
blobName,
});
closeModal(confirmModal);
if (result) {
await refreshBlobs();
}
});
openModal(confirmModal);
}
// ---------------------------------------------------------------------------
// SAS Token Modal
// ---------------------------------------------------------------------------
function openSasModal(
accountName: string,
containerName: string,
blobName?: string,
): void {
sasResultContainer.classList.add("hidden");
sasFormContainer.classList.remove("hidden");
const targetLabel = blobName
? `Blob: ${blobName}`
: `Container: ${containerName}`;
sasFormContainer.innerHTML = `
<div class="modal-title">Generate SAS Token</div>
<div class="form-group">
<label class="form-label">Storage Account</label>
<input class="form-input" type="text" value="${escapeHtml(accountName)}" readonly />
</div>
<div class="form-group">
<label class="form-label">Target</label>
<input class="form-input" type="text" value="${escapeHtml(targetLabel)}" readonly />
</div>
<div class="form-group">
<label class="form-label">Permissions</label>
<div class="permissions-grid">
<label class="permission-item">
<input type="checkbox" value="r" checked /> Read
</label>
<label class="permission-item">
<input type="checkbox" value="w" /> Write
</label>
<label class="permission-item">
<input type="checkbox" value="d" /> Delete
</label>
<label class="permission-item">
<input type="checkbox" value="l" checked /> List
</label>
<label class="permission-item">
<input type="checkbox" value="a" /> Add
</label>
<label class="permission-item">
<input type="checkbox" value="c" /> Create
</label>
</div>
</div>
<div class="form-group">
<label class="form-label">Expiry (hours)</label>
<input class="form-input" type="number" id="sas-expiry" value="24" min="1" max="8760" />
</div>
<div class="form-actions">
<button class="btn btn-secondary" id="sas-cancel">Cancel</button>
<button class="btn btn-primary" id="sas-generate">Generate Token</button>
</div>`;
document
.getElementById("sas-cancel")!
.addEventListener("click", () => closeModal(sasModal));
document
.getElementById("sas-generate")!
.addEventListener("click", () =>
handleSasGenerate(accountName, containerName, blobName),
);
openModal(sasModal);
}
async function handleSasGenerate(
accountName: string,
containerName: string,
blobName?: string,
): Promise<void> {
const checkboxes = sasFormContainer.querySelectorAll<HTMLInputElement>(
'input[type="checkbox"]:checked',
);
const permissions = Array.from(checkboxes)
.map((cb) => cb.value)
.join("");
if (!permissions) {
showError("Please select at least one permission.");
return;
}
const expiryInput =
sasFormContainer.querySelector<HTMLInputElement>("#sas-expiry")!;
const expiryHours = parseInt(expiryInput.value, 10) || 24;
const generateBtn =
sasFormContainer.querySelector<HTMLButtonElement>("#sas-generate")!;
generateBtn.disabled = true;
generateBtn.textContent = "Generating...";
const args: Record<string, unknown> = {
accountName,
containerName,
permissions,
expiryHours,
};
if (blobName) args.blobName = blobName;
const result = await callTool("generate_sas_token", args);
if (result) {
renderSasResult(result as unknown as SasTokenResult);
} else {
generateBtn.disabled = false;
generateBtn.textContent = "Generate Token";
}
}
function renderSasResult(data: SasTokenResult): void {
sasFormContainer.classList.add("hidden");
sasResultContainer.classList.remove("hidden");
sasResultContainer.innerHTML = `
<div class="sas-result">
<div class="sas-success-icon">✓</div>
<div class="sas-success-title">SAS Token Generated</div>
<div class="sas-field">
<div class="sas-field-label">SAS Token</div>
<div class="sas-field-value">
${escapeHtml(data.token)}
<button class="copy-btn" data-copy="token">Copy</button>
</div>
</div>
<div class="sas-field">
<div class="sas-field-label">Full URL</div>
<div class="sas-field-value">
${escapeHtml(data.url)}
<button class="copy-btn" data-copy="url">Copy</button>
</div>
</div>
<div class="sas-info">
<div><strong>Permissions:</strong> ${data.permissions
.split("")
.map((c) => escapeHtml(permissionLabel(c)))
.join(", ")}</div>
<div><strong>Expires:</strong> ${escapeHtml(formatDate(data.expiresAt))}</div>
</div>
<div class="sas-result-actions">
<button class="btn btn-secondary" id="sas-another">Generate Another</button>
<button class="btn btn-primary" id="sas-close">Close</button>
</div>
</div>`;
sasResultContainer
.querySelectorAll<HTMLButtonElement>(".copy-btn")
.forEach((btn) => {
btn.addEventListener("click", () => {
const field = btn.dataset.copy!;
const text = field === "token" ? data.token : data.url;
copyToClipboard(text, btn);
});
});
document
.getElementById("sas-another")!
.addEventListener("click", () => {
openSasModal(
data.accountName,
data.containerName,
data.blobName ?? undefined,
);
});
document
.getElementById("sas-close")!
.addEventListener("click", () => closeModal(sasModal));
}