mirror of
https://github.com/exo-explore/exo.git
synced 2026-02-19 15:27:02 -05:00
Compare commits
1 Commits
feat/prefi
...
feat/meta-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b0825335c7 |
232
dashboard/src/lib/components/MetaInstanceCard.svelte
Normal file
232
dashboard/src/lib/components/MetaInstanceCard.svelte
Normal file
@@ -0,0 +1,232 @@
|
||||
<script lang="ts">
|
||||
import type {
|
||||
MetaInstance,
|
||||
MetaInstanceStatus,
|
||||
NodeInfo,
|
||||
} from "$lib/stores/app.svelte";
|
||||
import {
|
||||
getMetaInstanceStatus,
|
||||
getMetaInstanceBackingNodes,
|
||||
topologyData,
|
||||
} from "$lib/stores/app.svelte";
|
||||
|
||||
interface Props {
|
||||
metaInstance: MetaInstance;
|
||||
onDelete?: (metaInstanceId: string) => void;
|
||||
}
|
||||
|
||||
let { metaInstance, onDelete }: Props = $props();
|
||||
|
||||
const status: MetaInstanceStatus = $derived(
|
||||
getMetaInstanceStatus(metaInstance),
|
||||
);
|
||||
const backingNodeIds: string[] = $derived(
|
||||
getMetaInstanceBackingNodes(metaInstance),
|
||||
);
|
||||
|
||||
const statusConfig = $derived.by(() => {
|
||||
switch (status) {
|
||||
case "active":
|
||||
return {
|
||||
label: "ACTIVE",
|
||||
dotClass: "bg-green-400",
|
||||
borderClass:
|
||||
"border-green-500/30 border-l-green-400",
|
||||
cornerClass: "border-green-500/50",
|
||||
glowClass: "shadow-[0_0_6px_rgba(74,222,128,0.4)]",
|
||||
animate: false,
|
||||
};
|
||||
case "provisioning":
|
||||
return {
|
||||
label: "PROVISIONING",
|
||||
dotClass: "bg-yellow-400",
|
||||
borderClass:
|
||||
"border-exo-yellow/30 border-l-yellow-400",
|
||||
cornerClass: "border-yellow-500/50",
|
||||
glowClass: "shadow-[0_0_6px_rgba(250,204,21,0.4)]",
|
||||
animate: true,
|
||||
};
|
||||
case "error":
|
||||
return {
|
||||
label: "ERROR",
|
||||
dotClass: "bg-red-400",
|
||||
borderClass: "border-red-500/30 border-l-red-400",
|
||||
cornerClass: "border-red-500/50",
|
||||
glowClass: "shadow-[0_0_6px_rgba(248,113,113,0.4)]",
|
||||
animate: false,
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
function getNodeName(nodeId: string): string {
|
||||
const topo = topologyData();
|
||||
if (!topo?.nodes) return nodeId.slice(0, 8);
|
||||
const node = topo.nodes[nodeId];
|
||||
return node?.friendly_name || node?.system_info?.model_id || nodeId.slice(0, 8);
|
||||
}
|
||||
|
||||
function formatModelId(modelId: string): string {
|
||||
// Show just the model name part after the org prefix
|
||||
const parts = modelId.split("/");
|
||||
return parts.length > 1 ? parts[parts.length - 1] : modelId;
|
||||
}
|
||||
|
||||
function handleDelete() {
|
||||
if (
|
||||
onDelete &&
|
||||
confirm(
|
||||
`Delete meta-instance for ${formatModelId(metaInstance.modelId)}?`,
|
||||
)
|
||||
) {
|
||||
onDelete(metaInstance.metaInstanceId);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="relative group">
|
||||
<!-- Corner accents -->
|
||||
<div
|
||||
class="absolute -top-px -left-px w-2 h-2 border-l border-t {statusConfig.cornerClass}"
|
||||
></div>
|
||||
<div
|
||||
class="absolute -top-px -right-px w-2 h-2 border-r border-t {statusConfig.cornerClass}"
|
||||
></div>
|
||||
<div
|
||||
class="absolute -bottom-px -left-px w-2 h-2 border-l border-b {statusConfig.cornerClass}"
|
||||
></div>
|
||||
<div
|
||||
class="absolute -bottom-px -right-px w-2 h-2 border-r border-b {statusConfig.cornerClass}"
|
||||
></div>
|
||||
|
||||
<div
|
||||
class="bg-exo-dark-gray/60 border border-l-2 {statusConfig.borderClass} p-3"
|
||||
>
|
||||
<!-- Header: Status + Delete -->
|
||||
<div class="flex justify-between items-start mb-2 pl-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<div
|
||||
class="w-1.5 h-1.5 {statusConfig.dotClass} rounded-full {statusConfig.glowClass} {statusConfig.animate
|
||||
? 'animate-pulse'
|
||||
: ''}"
|
||||
></div>
|
||||
<span
|
||||
class="text-xs font-mono tracking-[0.15em] uppercase {status === 'active'
|
||||
? 'text-green-400'
|
||||
: status === 'error'
|
||||
? 'text-red-400'
|
||||
: 'text-yellow-400'}"
|
||||
>
|
||||
{statusConfig.label}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onclick={handleDelete}
|
||||
class="text-xs px-2 py-1 font-mono tracking-wider uppercase border border-red-500/30 text-red-400 hover:bg-red-500/20 hover:text-red-400 hover:border-red-500/50 transition-all duration-200 cursor-pointer"
|
||||
>
|
||||
DELETE
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Model Info -->
|
||||
<div class="pl-2 space-y-1">
|
||||
<div class="text-exo-yellow text-xs font-mono tracking-wide truncate">
|
||||
{metaInstance.modelId}
|
||||
</div>
|
||||
|
||||
<!-- Sharding + Runtime badges -->
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class="inline-flex items-center px-1.5 py-0.5 text-[10px] font-mono tracking-wider uppercase border border-white/10 text-white/50"
|
||||
>
|
||||
{metaInstance.sharding}
|
||||
</span>
|
||||
<span
|
||||
class="inline-flex items-center px-1.5 py-0.5 text-[10px] font-mono tracking-wider uppercase border border-white/10 text-white/50"
|
||||
>
|
||||
{metaInstance.instanceMeta}
|
||||
</span>
|
||||
{#if metaInstance.minNodes > 1}
|
||||
<span
|
||||
class="inline-flex items-center px-1.5 py-0.5 text-[10px] font-mono tracking-wider uppercase border border-white/10 text-white/50"
|
||||
>
|
||||
{metaInstance.minNodes}+ nodes
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Node Assignments (when active) -->
|
||||
{#if backingNodeIds.length > 0}
|
||||
<div class="flex items-center gap-1.5 mt-1">
|
||||
<svg
|
||||
class="w-3 h-3 text-green-400/70 flex-shrink-0"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path
|
||||
d="M22 12h-4l-3 9L9 3l-3 9H2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
<span class="text-white/60 text-xs font-mono truncate">
|
||||
{backingNodeIds.map((id) => getNodeName(id)).join(", ")}
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Pinned nodes constraint -->
|
||||
{#if metaInstance.nodeIds && metaInstance.nodeIds.length > 0}
|
||||
<div class="flex items-center gap-1.5">
|
||||
<svg
|
||||
class="w-3 h-3 text-white/40 flex-shrink-0"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
|
||||
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
|
||||
</svg>
|
||||
<span class="text-white/40 text-[11px] font-mono">
|
||||
Pinned: {metaInstance.nodeIds
|
||||
.map((id) => getNodeName(id))
|
||||
.join(", ")}
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Error details -->
|
||||
{#if metaInstance.placementError}
|
||||
<div
|
||||
class="mt-1.5 p-2 bg-red-500/5 border border-red-500/15 rounded-sm"
|
||||
>
|
||||
<div class="text-red-400 text-[11px] font-mono leading-relaxed">
|
||||
{metaInstance.placementError}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Retry counter -->
|
||||
{#if metaInstance.consecutiveFailures > 0}
|
||||
<div class="flex items-center gap-1.5 mt-1">
|
||||
<svg
|
||||
class="w-3 h-3 text-yellow-500/60 flex-shrink-0"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<polyline points="23 4 23 10 17 10" />
|
||||
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10" />
|
||||
</svg>
|
||||
<span class="text-yellow-500/60 text-[11px] font-mono">
|
||||
{metaInstance.consecutiveFailures} consecutive
|
||||
failure{metaInstance.consecutiveFailures !== 1 ? "s" : ""}
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -14,21 +14,6 @@
|
||||
: 0,
|
||||
);
|
||||
|
||||
const etaText = $derived.by(() => {
|
||||
if (progress.processed <= 0 || progress.total <= 0) return null;
|
||||
const elapsedMs = performance.now() - progress.startedAt;
|
||||
if (elapsedMs < 200) return null; // need a minimum sample window
|
||||
const tokensPerMs = progress.processed / elapsedMs;
|
||||
const remainingTokens = progress.total - progress.processed;
|
||||
const remainingMs = remainingTokens / tokensPerMs;
|
||||
const remainingSec = Math.ceil(remainingMs / 1000);
|
||||
if (remainingSec <= 0) return null;
|
||||
if (remainingSec < 60) return `~${remainingSec}s remaining`;
|
||||
const mins = Math.floor(remainingSec / 60);
|
||||
const secs = remainingSec % 60;
|
||||
return `~${mins}m ${secs}s remaining`;
|
||||
});
|
||||
|
||||
function formatTokenCount(count: number | undefined): string {
|
||||
if (count == null) return "0";
|
||||
if (count >= 1000) {
|
||||
@@ -55,11 +40,8 @@
|
||||
style="width: {percentage}%"
|
||||
></div>
|
||||
</div>
|
||||
<div
|
||||
class="flex items-center justify-between text-xs text-exo-light-gray/70 mt-0.5 font-mono"
|
||||
>
|
||||
<span>{etaText ?? ""}</span>
|
||||
<span>{percentage}%</span>
|
||||
<div class="text-right text-xs text-exo-light-gray/70 mt-0.5 font-mono">
|
||||
{percentage}%
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -11,4 +11,5 @@ export { default as FamilySidebar } from "./FamilySidebar.svelte";
|
||||
export { default as HuggingFaceResultItem } from "./HuggingFaceResultItem.svelte";
|
||||
export { default as ModelFilterPopover } from "./ModelFilterPopover.svelte";
|
||||
export { default as ModelPickerGroup } from "./ModelPickerGroup.svelte";
|
||||
export { default as MetaInstanceCard } from "./MetaInstanceCard.svelte";
|
||||
export { default as ModelPickerModal } from "./ModelPickerModal.svelte";
|
||||
|
||||
@@ -72,8 +72,23 @@ export interface Instance {
|
||||
runnerToShard?: Record<string, unknown>;
|
||||
nodeToRunner?: Record<string, string>;
|
||||
};
|
||||
metaInstanceId?: string | null;
|
||||
}
|
||||
|
||||
export interface MetaInstance {
|
||||
metaInstanceId: string;
|
||||
modelId: string;
|
||||
sharding: "Pipeline" | "Tensor";
|
||||
instanceMeta: "MlxRing" | "MlxJaccl";
|
||||
minNodes: number;
|
||||
nodeIds: string[] | null;
|
||||
placementError: string | null;
|
||||
consecutiveFailures: number;
|
||||
lastFailureError: string | null;
|
||||
}
|
||||
|
||||
export type MetaInstanceStatus = "active" | "provisioning" | "error";
|
||||
|
||||
// Granular node state types from the new state structure
|
||||
interface RawNodeIdentity {
|
||||
modelId?: string;
|
||||
@@ -223,6 +238,7 @@ interface RawStateResponse {
|
||||
MlxJacclInstance?: Instance;
|
||||
}
|
||||
>;
|
||||
metaInstances?: Record<string, MetaInstance>;
|
||||
runners?: Record<string, unknown>;
|
||||
downloads?: Record<string, unknown[]>;
|
||||
// New granular node state fields
|
||||
@@ -276,8 +292,6 @@ export interface TokenData {
|
||||
export interface PrefillProgress {
|
||||
processed: number;
|
||||
total: number;
|
||||
/** Timestamp (performance.now()) when prefill started. */
|
||||
startedAt: number;
|
||||
}
|
||||
|
||||
export interface Message {
|
||||
@@ -535,6 +549,7 @@ class AppStore {
|
||||
// Topology state
|
||||
topologyData = $state<TopologyData | null>(null);
|
||||
instances = $state<Record<string, unknown>>({});
|
||||
metaInstances = $state<Record<string, MetaInstance>>({});
|
||||
runners = $state<Record<string, unknown>>({});
|
||||
downloads = $state<Record<string, unknown[]>>({});
|
||||
nodeDisk = $state<
|
||||
@@ -1270,6 +1285,9 @@ class AppStore {
|
||||
this.instances = data.instances;
|
||||
this.refreshConversationModelFromInstances();
|
||||
}
|
||||
if (data.metaInstances) {
|
||||
this.metaInstances = data.metaInstances;
|
||||
}
|
||||
if (data.runners) {
|
||||
this.runners = data.runners;
|
||||
}
|
||||
@@ -1295,6 +1313,79 @@ class AppStore {
|
||||
}
|
||||
}
|
||||
|
||||
async createMetaInstance(
|
||||
modelId: string,
|
||||
sharding: "Pipeline" | "Tensor" = "Pipeline",
|
||||
instanceMeta: "MlxRing" | "MlxJaccl" = "MlxRing",
|
||||
minNodes: number = 1,
|
||||
nodeIds: string[] | null = null,
|
||||
) {
|
||||
try {
|
||||
const response = await fetch("/meta_instance", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
model_id: modelId,
|
||||
sharding,
|
||||
instance_meta: instanceMeta,
|
||||
min_nodes: minNodes,
|
||||
node_ids: nodeIds,
|
||||
}),
|
||||
});
|
||||
if (!response.ok) {
|
||||
console.error("Failed to create meta-instance:", response.status);
|
||||
}
|
||||
await this.fetchState();
|
||||
} catch (error) {
|
||||
console.error("Error creating meta-instance:", error);
|
||||
}
|
||||
}
|
||||
|
||||
async deleteMetaInstance(metaInstanceId: string) {
|
||||
try {
|
||||
const response = await fetch(`/meta_instance/${metaInstanceId}`, {
|
||||
method: "DELETE",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
if (!response.ok) {
|
||||
console.error("Failed to delete meta-instance:", response.status);
|
||||
}
|
||||
await this.fetchState();
|
||||
} catch (error) {
|
||||
console.error("Error deleting meta-instance:", error);
|
||||
}
|
||||
}
|
||||
|
||||
getMetaInstanceStatus(
|
||||
metaInstance: MetaInstance,
|
||||
): MetaInstanceStatus {
|
||||
// Check if any running instance is bound to this meta-instance
|
||||
for (const instanceWrapper of Object.values(this.instances)) {
|
||||
if (!instanceWrapper || typeof instanceWrapper !== "object") continue;
|
||||
const keys = Object.keys(instanceWrapper as Record<string, unknown>);
|
||||
if (keys.length !== 1) continue;
|
||||
const inner = (instanceWrapper as Record<string, unknown>)[keys[0]];
|
||||
if (inner && typeof inner === "object" && (inner as Instance).metaInstanceId === metaInstance.metaInstanceId) {
|
||||
return "active";
|
||||
}
|
||||
}
|
||||
if (metaInstance.placementError) return "error";
|
||||
return "provisioning";
|
||||
}
|
||||
|
||||
getMetaInstanceBackingNodes(metaInstance: MetaInstance): string[] {
|
||||
for (const instanceWrapper of Object.values(this.instances)) {
|
||||
if (!instanceWrapper || typeof instanceWrapper !== "object") continue;
|
||||
const keys = Object.keys(instanceWrapper as Record<string, unknown>);
|
||||
if (keys.length !== 1) continue;
|
||||
const inner = (instanceWrapper as Record<string, unknown>)[keys[0]] as Instance;
|
||||
if (inner?.metaInstanceId === metaInstance.metaInstanceId && inner?.shardAssignments?.nodeToRunner) {
|
||||
return Object.keys(inner.shardAssignments.nodeToRunner);
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
async fetchPlacementPreviews(modelId: string, showLoading = true) {
|
||||
if (!modelId) return;
|
||||
|
||||
@@ -2461,7 +2552,6 @@ class AppStore {
|
||||
this.prefillProgress = {
|
||||
processed: inner.processed_tokens,
|
||||
total: inner.total_tokens,
|
||||
startedAt: this.prefillProgress?.startedAt ?? performance.now(),
|
||||
};
|
||||
},
|
||||
},
|
||||
@@ -3157,6 +3247,7 @@ export const totalTokens = () => appStore.totalTokens;
|
||||
export const prefillProgress = () => appStore.prefillProgress;
|
||||
export const topologyData = () => appStore.topologyData;
|
||||
export const instances = () => appStore.instances;
|
||||
export const metaInstances = () => appStore.metaInstances;
|
||||
export const runners = () => appStore.runners;
|
||||
export const downloads = () => appStore.downloads;
|
||||
export const nodeDisk = () => appStore.nodeDisk;
|
||||
@@ -3245,6 +3336,21 @@ export const setChatSidebarVisible = (visible: boolean) =>
|
||||
appStore.setChatSidebarVisible(visible);
|
||||
export const refreshState = () => appStore.fetchState();
|
||||
|
||||
// Meta-instance actions
|
||||
export const createMetaInstance = (
|
||||
modelId: string,
|
||||
sharding?: "Pipeline" | "Tensor",
|
||||
instanceMeta?: "MlxRing" | "MlxJaccl",
|
||||
minNodes?: number,
|
||||
nodeIds?: string[] | null,
|
||||
) => appStore.createMetaInstance(modelId, sharding, instanceMeta, minNodes, nodeIds);
|
||||
export const deleteMetaInstance = (metaInstanceId: string) =>
|
||||
appStore.deleteMetaInstance(metaInstanceId);
|
||||
export const getMetaInstanceStatus = (metaInstance: MetaInstance) =>
|
||||
appStore.getMetaInstanceStatus(metaInstance);
|
||||
export const getMetaInstanceBackingNodes = (metaInstance: MetaInstance) =>
|
||||
appStore.getMetaInstanceBackingNodes(metaInstance);
|
||||
|
||||
// Node identities (for OS version mismatch detection)
|
||||
export const nodeIdentities = () => appStore.nodeIdentities;
|
||||
|
||||
|
||||
@@ -47,10 +47,14 @@
|
||||
thunderboltBridgeCycles,
|
||||
nodeThunderboltBridge,
|
||||
nodeIdentities,
|
||||
metaInstances,
|
||||
deleteMetaInstance,
|
||||
type DownloadProgress,
|
||||
type PlacementPreview,
|
||||
type MetaInstance,
|
||||
} from "$lib/stores/app.svelte";
|
||||
import HeaderNav from "$lib/components/HeaderNav.svelte";
|
||||
import MetaInstanceCard from "$lib/components/MetaInstanceCard.svelte";
|
||||
import { fade, fly } from "svelte/transition";
|
||||
import { cubicInOut } from "svelte/easing";
|
||||
import { onMount } from "svelte";
|
||||
@@ -67,6 +71,8 @@
|
||||
const loadingPreviews = $derived(isLoadingPreviews());
|
||||
const debugEnabled = $derived(debugMode());
|
||||
const topologyOnlyEnabled = $derived(topologyOnlyMode());
|
||||
const metaInstanceData = $derived(metaInstances());
|
||||
const metaInstanceCount = $derived(Object.keys(metaInstanceData).length);
|
||||
const sidebarVisible = $derived(chatSidebarVisible());
|
||||
const tbBridgeCycles = $derived(thunderboltBridgeCycles());
|
||||
const tbBridgeData = $derived(nodeThunderboltBridge());
|
||||
@@ -3056,6 +3062,39 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Meta-Instances Panel -->
|
||||
{#if metaInstanceCount > 0}
|
||||
<div class="p-4 flex-shrink-0 border-t border-exo-yellow/10">
|
||||
<!-- Panel Header -->
|
||||
<div class="flex items-center gap-2 mb-4">
|
||||
<div
|
||||
class="w-2 h-2 border border-purple-400/60 rotate-45"
|
||||
></div>
|
||||
<h3
|
||||
class="text-xs text-purple-400 font-mono tracking-[0.2em] uppercase"
|
||||
>
|
||||
Meta-Instances
|
||||
</h3>
|
||||
<div
|
||||
class="flex-1 h-px bg-gradient-to-r from-purple-400/30 to-transparent"
|
||||
></div>
|
||||
<span class="text-[10px] text-white/40 font-mono"
|
||||
>{metaInstanceCount}</span
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
{#each Object.entries(metaInstanceData) as [id, mi]}
|
||||
<MetaInstanceCard
|
||||
metaInstance={mi}
|
||||
onDelete={(metaInstanceId) =>
|
||||
deleteMetaInstance(metaInstanceId)}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Models Panel - Scrollable -->
|
||||
<div class="p-4 flex-1 overflow-y-auto">
|
||||
<!-- Panel Header -->
|
||||
@@ -3878,6 +3917,34 @@
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Meta-Instances Section (chat sidebar) -->
|
||||
{#if metaInstanceCount > 0}
|
||||
<div class="p-4 border-t border-exo-yellow/10">
|
||||
<div class="flex items-center gap-2 mb-4">
|
||||
<div
|
||||
class="w-2 h-2 border border-purple-400/60 rotate-45"
|
||||
></div>
|
||||
<h3
|
||||
class="text-xs text-purple-400 font-mono tracking-[0.2em] uppercase"
|
||||
>
|
||||
Meta-Instances
|
||||
</h3>
|
||||
<div
|
||||
class="flex-1 h-px bg-gradient-to-r from-purple-400/30 to-transparent"
|
||||
></div>
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
{#each Object.entries(metaInstanceData) as [id, mi]}
|
||||
<MetaInstanceCard
|
||||
metaInstance={mi}
|
||||
onDelete={(metaInstanceId) =>
|
||||
deleteMetaInstance(metaInstanceId)}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</aside>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 110 KiB |
Reference in New Issue
Block a user