テーブルは数える。可視化は、見る
集計APIと ダッシュボードで、ログを「数えて」きました。top offenders、サブネット別件数——数字は正確です。 でも数字は、構造を見せてくれません。
アクセスログは本質的に大量の点です。時刻があり、アクセス元があり、種別がある。 これを座標に置いて描くと、テーブルでは埋もれていたパターンが形として現れます。 そして大量の点を滑らかに描くのは、ティックベンチで見たとおり WebGPU の得意分野です。
log を、座標へ
各アクセスを、1つの点に変換します。座標の割り当てはこう置きました:
X = 時刻(左→右で時間経過)、Y = サブネット(同じ /24 は同じ高さに揃う)、 色 = 分類(正常=シアン、攻撃=赤、正規クローラ=緑、その他=琥珀。ダッシュボードの分類と一貫)、 輝度 = 濃さ(短時間の連続や密集で強くなる)。
// 1点 = { x:時刻[0,1], y:サブネット[0,1], cls:分類, intensity:濃さ } function genGalaxy(total, withAttack) { const pts = []; // 正常トラフィック:全体に淡く散る星々 for (let i=0; i<total*0.62; i++) pts.push({ x:rand(), y:rand(), cls:0, intensity:0.15+rand()*0.2 }); if (withAttack) { // 分散スキャン:同一サブネット(同じY)・短時間(狭いX) → 横一列の赤帯 for (let i=0; i<total*0.12; i++) pts.push({ x:0.55+rand()*0.2, y:0.35+jitter(), cls:1, intensity:0.5+rand()*0.4 }); // 単発集中:一点に密集 → 輝く塊 for (let i=0; i<total*0.08; i++) pts.push({ x:0.30+jitter(), y:0.68+jitter(), cls:1, intensity:0.7+rand()*0.3 }); } return pts; }
点は Float32Array に [x, y, cls, intensity] の順で詰め、GPU の
インスタンスバッファとして一括で渡します(stride 16 バイト)。数万点でも、転送は一度きりです。
1点を、光の粒に
各点を、小さな四角(ビルボード)として instanced 描画します。頂点シェーダで点の位置に四角を置き、 フラグメントシェーダで中心が明るく縁が消える円にする。これが「星」1粒です。
struct U { aspect: f32, pointSize: f32, glow: f32, time: f32, }; // バインディングは必ず全部使う(layout:'auto' は未使用を落とす) @group(0) @binding(0) var<uniform> u: U; fn classColor(c: f32) -> vec3<f32> { if (c < 0.5) { return vec3<f32>(0.0, 0.85, 1.0); } // 正常=シアン if (c < 1.5) { return vec3<f32>(1.0, 0.18, 0.30); } // 攻撃=赤 if (c < 2.5) { return vec3<f32>(0.22, 1.0, 0.30); } // 正規=緑 return vec3<f32>(1.0, 0.70, 0.12); // その他=琥珀 } @vertex fn vs(@location(0) corner: vec2<f32>, // 四角の隅(-1..1) @location(1) inst: vec2<f32>, // 点の x,y(インスタンス) @location(2) ci: vec2<f32>) -> VSOut { // cls, intensity var out: VSOut; let center = vec2<f32>(inst.x*2.0-1.0, inst.y*2.0-1.0); let size = u.pointSize * (0.5 + ci.y); // aspect補正で点を真円に out.pos = vec4<f32>(center + vec2<f32>(corner.x*size/u.aspect, corner.y*size), 0.0, 1.0); out.uv = corner; out.color = classColor(ci.x); out.alpha = ci.y * u.glow; return out; } @fragment fn fs(in: VSOut) -> @location(0) vec4<f32> { let a = smoothstep(1.0, 0.0, length(in.uv)) * in.alpha; // 円形フォールオフ return vec4<f32>(in.color * a, a); }
layout:'auto' は使っていないバインディングを黙って落とすので、宣言した u は全フィールド使い切る。
これは WGSL を書くときの毎回の作法です。今回は描画だけなので float32 テクスチャは登場しませんが、
使うなら rgba32float と textureLoad の原則も同じく効きます。
加算ブレンドで、密集が発光する
「銀河」らしさの核心は加算ブレンドです。点が重なるほど色が足し算されて明るくなる。 散らばった正常アクセスは淡い星のまま、密集した攻撃は白く輝く。数密度が、そのまま輝度になります。
blend: {
color: { srcFactor: 'one', dstFactor: 'one', operation: 'add' },
alpha: { srcFactor: 'one', dstFactor: 'one', operation: 'add' },
}
// クリアは暗い宇宙色。点の色が足し合わされて銀河になる
clearValue: { r:0.02, g:0.03, b:0.05, a:1 }
これで、同一サブネットに短時間で集まる分散スキャンは赤い帯として横に伸び、 一点に殺到する単発集中は輝く塊になります。テーブルで「192.0.2.0/24 が96件」と読むより、 赤い塊が光っている方が、脅威は一瞬で伝わります。
ダッシュボードの「分散(ip_count多) / 単発集中(ip_count=1)」の区別が、そのまま銀河では「帯 / 塊」という形の違いになります。数える側と見る側で、同じ構造を別の言語で語っているわけです。
Pixel 10 Pro と iPad mini A17 Pro で、描画は違う
描画性能もまた端末で差が出ます。WebLLM の decode が8倍違ったように、 点群描画のフレームレートも Pixel 10 Pro と iPad mini A17 Pro で変わります。 加算ブレンドは fill-rate(塗り面積)を食うので、点が大きく密集するほど GPU 負荷が上がります。
ログの銀河
擬似ログを WebGPU で描きます。攻撃注入を切り替えると、分散スキャンの赤い帯と単発集中の輝く塊が 現れたり消えたりします。点数スライダーで、あなたの端末の描画の余裕も見えます。
攻撃をONにすると、画面の中ほどに赤い帯(分散スキャン)と、 その下に輝く塊(単発集中)が浮かびます。OFFにすると、 あとには淡いシアンの星々(正常)と、規則的な緑の点(正規クローラ)だけが残る。 異常が"形"として見える——これが可視化の力です。
数えると、見る
ログ基盤には、2つの目が要ります。数える(集計API・ダッシュボード)と、見る(銀河)。 数字は正確さを、可視化は構造を教えてくれる。WebGPU の instanced rendering と加算ブレンドがあれば、 数万の点を滑らかに描き、攻撃を"形"として浮かび上がらせられます。
この記事のまとめ
- ログは大量の点。テーブルは数え、可視化は見る。両方あって全体が分かる。
- X=時刻 / Y=サブネット / 色=分類 / 輝度=密度、で座標へ写す。
- instanced billboard + 円形フォールオフで、1点を光の粒に。
- 加算ブレンドで数密度が輝度になる。分散=帯、単発集中=塊。
- WGSL標準ルール(全バインディング使用・
rgba32float・textureLoad)はここでも効く。
擬似データで構造を確かめたら、次は本番ログを流す番です。集計APIに時刻・サブネット・分類の点列を返すエンドポイントを足せば、 この銀河はそのまま実データのリアルタイム可視化になります。数える基盤の上に、見る目を重ねる——ログ基盤の、次の一手です。